diff --git a/pkg/lumera/modules/audit/assignment.go b/pkg/lumera/modules/audit/assignment.go new file mode 100644 index 00000000..de2d8759 --- /dev/null +++ b/pkg/lumera/modules/audit/assignment.go @@ -0,0 +1,76 @@ +package audit + +import ( + "fmt" + "strings" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +// AssignedTarget separates the identity frozen into the epoch assignment from +// the account that currently owns and serves that identity. +type AssignedTarget struct { + LogicalAccount string + CurrentAccount string +} + +// AssignedTargets is the validated, continuity-aware projection of the chain +// response. Logical accounts are used in reports and deterministic transcripts; +// current accounts are used only for live network routing. +type AssignedTargets struct { + EpochID uint64 + ReporterAccount string + RequiredOpenPorts []uint32 + Targets []AssignedTarget +} + +// ResolveAssignedTargets validates the chain-provided identity mappings. It +// deliberately does not consult SuperNode.PrevSupernodeAccounts: only Audit's +// indexed lineage is authoritative for an epoch assignment. +func ResolveAssignedTargets(resp *audittypes.QueryAssignedTargetsResponse, requestedEpoch uint64) (AssignedTargets, error) { + if resp == nil { + return AssignedTargets{}, fmt.Errorf("assigned targets response is nil") + } + if resp.EpochId != requestedEpoch { + return AssignedTargets{}, fmt.Errorf("assigned targets epoch mismatch: got %d, want %d", resp.EpochId, requestedEpoch) + } + reporter := strings.TrimSpace(resp.ReporterSupernodeAccount) + if reporter == "" { + return AssignedTargets{}, fmt.Errorf("assigned targets response is missing logical reporter") + } + if len(resp.TargetAccountMappings) != len(resp.TargetSupernodeAccounts) { + return AssignedTargets{}, fmt.Errorf("assigned targets mapping count mismatch: got %d mappings for %d targets", len(resp.TargetAccountMappings), len(resp.TargetSupernodeAccounts)) + } + + resolved := AssignedTargets{ + EpochID: resp.EpochId, + ReporterAccount: reporter, + RequiredOpenPorts: append([]uint32(nil), resp.RequiredOpenPorts...), + Targets: make([]AssignedTarget, len(resp.TargetSupernodeAccounts)), + } + seenLogical := make(map[string]struct{}, len(resolved.Targets)) + seenCurrent := make(map[string]struct{}, len(resolved.Targets)) + for i, expected := range resp.TargetSupernodeAccounts { + logical := strings.TrimSpace(resp.TargetAccountMappings[i].LogicalAccount) + current := strings.TrimSpace(resp.TargetAccountMappings[i].CurrentAccount) + if logical == "" || current == "" { + return AssignedTargets{}, fmt.Errorf("assigned target mapping %d has an empty account", i) + } + if logical != strings.TrimSpace(expected) { + return AssignedTargets{}, fmt.Errorf("assigned target mapping %d logical account mismatch: got %q, want %q", i, logical, expected) + } + if _, exists := seenLogical[logical]; exists { + return AssignedTargets{}, fmt.Errorf("assigned target mapping duplicates logical account %q", logical) + } + if logical == reporter { + return AssignedTargets{}, fmt.Errorf("assigned target mapping duplicates logical reporter %q", reporter) + } + if _, exists := seenCurrent[current]; exists { + return AssignedTargets{}, fmt.Errorf("assigned target mapping aliases current account %q", current) + } + seenLogical[logical] = struct{}{} + seenCurrent[current] = struct{}{} + resolved.Targets[i] = AssignedTarget{LogicalAccount: logical, CurrentAccount: current} + } + return resolved, nil +} diff --git a/pkg/lumera/modules/audit/assignment_test.go b/pkg/lumera/modules/audit/assignment_test.go new file mode 100644 index 00000000..d2fd6d37 --- /dev/null +++ b/pkg/lumera/modules/audit/assignment_test.go @@ -0,0 +1,85 @@ +package audit + +import ( + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/stretchr/testify/require" +) + +func assignedResponse(epoch uint64, reporter string, logical, current []string) *audittypes.QueryAssignedTargetsResponse { + mappings := make([]audittypes.AccountIdentityMapping, len(logical)) + for i := range logical { + mappings[i] = audittypes.AccountIdentityMapping{LogicalAccount: logical[i], CurrentAccount: current[i]} + } + return &audittypes.QueryAssignedTargetsResponse{ + EpochId: epoch, + ReporterSupernodeAccount: reporter, + TargetSupernodeAccounts: append([]string(nil), logical...), + TargetAccountMappings: mappings, + RequiredOpenPorts: []uint32{4444, 5555}, + } +} + +func TestResolveAssignedTargetsMigrationMatrix(t *testing.T) { + tests := []struct { + name string + requested string + reporterLogical string + targetLogical string + targetCurrent string + }{ + {name: "unmigrated", requested: "reporter-A", reporterLogical: "reporter-A", targetLogical: "target-A", targetCurrent: "target-A"}, + {name: "reporter-only", requested: "reporter-B", reporterLogical: "reporter-A", targetLogical: "target-A", targetCurrent: "target-A"}, + {name: "target-only", requested: "reporter-A", reporterLogical: "reporter-A", targetLogical: "target-A", targetCurrent: "target-B"}, + {name: "both", requested: "reporter-B", reporterLogical: "reporter-A", targetLogical: "target-A", targetCurrent: "target-B"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := assignedResponse(17, tt.reporterLogical, []string{tt.targetLogical}, []string{tt.targetCurrent}) + got, err := ResolveAssignedTargets(resp, 17) + require.NoError(t, err) + require.Equal(t, tt.reporterLogical, got.ReporterAccount) + require.Equal(t, []AssignedTarget{{LogicalAccount: tt.targetLogical, CurrentAccount: tt.targetCurrent}}, got.Targets) + // The request account is deliberately independent: the adapter must + // trust the chain's epoch-logical reporter projection. + require.NotEmpty(t, tt.requested) + }) + } +} + +func TestResolveAssignedTargetsRejectsNextEpochAndMalformedMappings(t *testing.T) { + valid := func() *audittypes.QueryAssignedTargetsResponse { + return assignedResponse(21, "reporter-A", []string{"target-A", "target-C"}, []string{"target-B", "target-D"}) + } + tests := []struct { + name string + mutate func(*audittypes.QueryAssignedTargetsResponse) + want string + }{ + {name: "next epoch", mutate: func(r *audittypes.QueryAssignedTargetsResponse) { r.EpochId = 22 }, want: "epoch mismatch"}, + {name: "missing reporter", mutate: func(r *audittypes.QueryAssignedTargetsResponse) { r.ReporterSupernodeAccount = " " }, want: "missing logical reporter"}, + {name: "mapping count", mutate: func(r *audittypes.QueryAssignedTargetsResponse) { + r.TargetAccountMappings = r.TargetAccountMappings[:1] + }, want: "mapping count mismatch"}, + {name: "mapping order", mutate: func(r *audittypes.QueryAssignedTargetsResponse) { + r.TargetAccountMappings[0], r.TargetAccountMappings[1] = r.TargetAccountMappings[1], r.TargetAccountMappings[0] + }, want: "logical account mismatch"}, + {name: "duplicate logical", mutate: func(r *audittypes.QueryAssignedTargetsResponse) { + r.TargetSupernodeAccounts[1] = "target-A" + r.TargetAccountMappings[1].LogicalAccount = "target-A" + }, want: "duplicates logical"}, + {name: "duplicate current", mutate: func(r *audittypes.QueryAssignedTargetsResponse) { + r.TargetAccountMappings[1].CurrentAccount = "target-B" + }, want: "aliases current"}, + {name: "empty account", mutate: func(r *audittypes.QueryAssignedTargetsResponse) { r.TargetAccountMappings[0].CurrentAccount = " " }, want: "empty account"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := valid() + tt.mutate(resp) + _, err := ResolveAssignedTargets(resp, 21) + require.ErrorContains(t, err, tt.want) + }) + } +} diff --git a/supernode/host_reporter/service.go b/supernode/host_reporter/service.go index 9a68aafe..f7f8890a 100644 --- a/supernode/host_reporter/service.go +++ b/supernode/host_reporter/service.go @@ -19,6 +19,7 @@ import ( "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" "github.com/LumeraProtocol/supernode/v2/pkg/lumera" "github.com/LumeraProtocol/supernode/v2/pkg/lumera/chainerrors" + auditmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/audit" "github.com/LumeraProtocol/supernode/v2/pkg/reachability" statussvc "github.com/LumeraProtocol/supernode/v2/supernode/status" "github.com/cosmos/cosmos-sdk/crypto/keyring" @@ -174,24 +175,31 @@ func (s *Service) tick(ctx context.Context) { return } - // Idempotency: if a report exists for this epoch, do nothing. - if _, err := s.lumera.Audit().GetEpochReport(tickCtx, epochID, s.identity); err == nil { + assignResp, err := s.lumera.Audit().GetAssignedTargets(tickCtx, s.identity, epochID) + if err != nil || assignResp == nil { return - } else if status.Code(err) != codes.NotFound { + } + assignment, err := auditmod.ResolveAssignedTargets(assignResp, epochID) + if err != nil { + logtrace.Warn(tickCtx, "epoch report skipped: invalid identity assignment", logtrace.Fields{"epoch_id": epochID, "error": err.Error()}) return } - assignResp, err := s.lumera.Audit().GetAssignedTargets(tickCtx, s.identity, epochID) - if err != nil || assignResp == nil { + // Idempotency is keyed by the epoch-logical reporter, while the transaction + // is still signed and submitted by the configured current account. + if _, err := s.lumera.Audit().GetEpochReport(tickCtx, epochID, assignment.ReporterAccount); err == nil { + return + } else if status.Code(err) != codes.NotFound { return } - storageChallengeObservations := s.buildStorageChallengeObservations(tickCtx, epochID, assignResp.RequiredOpenPorts, assignResp.TargetSupernodeAccounts) + storageChallengeObservations := s.buildStorageChallengeObservations(tickCtx, epochID, assignment.RequiredOpenPorts, assignment.Targets) var storageProofResults []*audittypes.StorageProofResult proofResultProvider := s.getProofResultProvider() if proofResultProvider != nil { storageProofResults = proofResultProvider.CollectResults(epochID) + storageProofResults = compatibleProofResults(storageProofResults, assignment) mode, modeOK := s.storageTruthEnforcementMode(tickCtx) if modeOK && mode == audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL { // FULL mode is the only mode where the chain enforces compound @@ -201,18 +209,18 @@ func (s *Service) tick(ctx context.Context) { // drain doesn't satisfy that, we MUST skip this epoch and // requeue the partial rows so the next tick can try again with // a complete set. - complete, reason := storageProofCoverageComplete(storageProofResults, assignResp.TargetSupernodeAccounts) + complete, reason := storageProofCoverageComplete(storageProofResults, logicalTargetAccounts(assignment.Targets)) if !complete { requeueProofResults(proofResultProvider, epochID, storageProofResults) logtrace.Warn(tickCtx, "epoch report skipped: incomplete FULL-mode storage proof coverage", logtrace.Fields{ "epoch_id": epochID, - "assigned_targets": len(assignResp.TargetSupernodeAccounts), + "assigned_targets": len(assignment.Targets), "proof_results": len(storageProofResults), "reason": reason, }) return } - } else if modeOK && len(assignResp.TargetSupernodeAccounts) > 0 && len(storageProofResults) == 0 { + } else if modeOK && len(assignment.Targets) > 0 && len(storageProofResults) == 0 { // SHADOW / SOFT / UNSPECIFIED: chain accepts empty StorageProofResults // (only FULL enforces compound coverage). Submitting the host / // peer-observation report is mandatory regardless — withholding it @@ -224,7 +232,7 @@ func (s *Service) tick(ctx context.Context) { // scoring (LEP-6 PR286 review F1). logtrace.Info(tickCtx, "epoch report: submitting in non-FULL mode with empty LEP-6 proof rows", logtrace.Fields{ "epoch_id": epochID, - "assigned_targets": len(assignResp.TargetSupernodeAccounts), + "assigned_targets": len(assignment.Targets), "mode": mode.String(), }) } @@ -305,6 +313,36 @@ func requeueProofResults(provider ProofResultProvider, epochID uint64, results [ } } +func logicalTargetAccounts(targets []auditmod.AssignedTarget) []string { + out := make([]string, len(targets)) + for i := range targets { + out[i] = targets[i].LogicalAccount + } + return out +} + +// compatibleProofResults implements the migration buffer policy. Rows built +// with current/pre-migration identities cannot be repaired because their +// transcript and signature cover those identities; discard them so the +// dispatcher rebuilds them from the authoritative assignment. +func compatibleProofResults(results []*audittypes.StorageProofResult, assignment auditmod.AssignedTargets) []*audittypes.StorageProofResult { + allowed := make(map[string]struct{}, len(assignment.Targets)) + for _, target := range assignment.Targets { + allowed[target.LogicalAccount] = struct{}{} + } + out := make([]*audittypes.StorageProofResult, 0, len(results)) + for _, result := range results { + if result == nil || result.ChallengerSupernodeAccount != assignment.ReporterAccount { + continue + } + if _, ok := allowed[result.TargetSupernodeAccount]; !ok { + continue + } + out = append(out, result) + } + return out +} + func storageProofCoverageComplete(results []*audittypes.StorageProofResult, targets []string) (bool, string) { if len(targets) == 0 { return true, "" @@ -436,7 +474,7 @@ func (s *Service) cascadeKademliaDBBytes(_ context.Context) (uint64, bool) { return total, true } -func (s *Service) buildStorageChallengeObservations(ctx context.Context, epochID uint64, requiredOpenPorts []uint32, targets []string) []*audittypes.StorageChallengeObservation { +func (s *Service) buildStorageChallengeObservations(ctx context.Context, epochID uint64, requiredOpenPorts []uint32, targets []auditmod.AssignedTarget) []*audittypes.StorageChallengeObservation { if len(targets) == 0 { return nil } @@ -445,7 +483,7 @@ func (s *Service) buildStorageChallengeObservations(ctx context.Context, epochID type workItem struct { index int - target string + target auditmod.AssignedTarget } work := make(chan workItem) @@ -485,17 +523,19 @@ func (s *Service) buildStorageChallengeObservations(ctx context.Context, epochID return final } -func (s *Service) observeTarget(ctx context.Context, epochID uint64, requiredOpenPorts []uint32, target string) *audittypes.StorageChallengeObservation { - target = strings.TrimSpace(target) - if target == "" { +func (s *Service) observeTarget(ctx context.Context, epochID uint64, requiredOpenPorts []uint32, target auditmod.AssignedTarget) *audittypes.StorageChallengeObservation { + logicalTarget := strings.TrimSpace(target.LogicalAccount) + currentTarget := strings.TrimSpace(target.CurrentAccount) + if logicalTarget == "" || currentTarget == "" { return nil } - host, err := s.targetHost(ctx, target) + host, err := s.targetHost(ctx, currentTarget) if err != nil { logtrace.Warn(ctx, "storage challenge observe target: resolve host failed", logtrace.Fields{ "epoch_id": epochID, - "target": target, + "target": logicalTarget, + "current": currentTarget, "error": err.Error(), }) host = "" @@ -507,7 +547,7 @@ func (s *Service) observeTarget(ctx context.Context, epochID uint64, requiredOpe } return &audittypes.StorageChallengeObservation{ - TargetSupernodeAccount: target, + TargetSupernodeAccount: logicalTarget, PortStates: portStates, } } diff --git a/supernode/host_reporter/tick_behavior_test.go b/supernode/host_reporter/tick_behavior_test.go index 4db639df..bc69af21 100644 --- a/supernode/host_reporter/tick_behavior_test.go +++ b/supernode/host_reporter/tick_behavior_test.go @@ -46,6 +46,16 @@ func (s *stubAuditModule) GetCurrentEpochAnchor(ctx context.Context) (*audittype return &audittypes.QueryCurrentEpochAnchorResponse{}, nil } func (s *stubAuditModule) GetAssignedTargets(ctx context.Context, supernodeAccount string, epochID uint64) (*audittypes.QueryAssignedTargetsResponse, error) { + // Keep pre-continuity fixtures concise; migration-specific tests populate + // these fields explicitly. + if s.assigned != nil && s.assigned.ReporterSupernodeAccount == "" { + s.assigned.EpochId = epochID + s.assigned.ReporterSupernodeAccount = supernodeAccount + s.assigned.TargetAccountMappings = make([]audittypes.AccountIdentityMapping, len(s.assigned.TargetSupernodeAccounts)) + for i, target := range s.assigned.TargetSupernodeAccounts { + s.assigned.TargetAccountMappings[i] = audittypes.AccountIdentityMapping{LogicalAccount: target, CurrentAccount: target} + } + } return s.assigned, nil } func (s *stubAuditModule) GetEpochReport(ctx context.Context, epochID uint64, supernodeAccount string) (*audittypes.QueryEpochReportResponse, error) { @@ -279,7 +289,9 @@ func TestTick_AttachedProofResultProviderIsDrainedAndForwarded(t *testing.T) { currentEpoch: &audittypes.QueryCurrentEpochResponse{EpochId: 11}, anchor: &audittypes.QueryEpochAnchorResponse{Anchor: audittypes.EpochAnchor{EpochId: 11}}, epochReportErr: status.Error(codes.NotFound, "not found"), - assigned: &audittypes.QueryAssignedTargetsResponse{}, + assigned: &audittypes.QueryAssignedTargetsResponse{ + TargetSupernodeAccounts: []string{"snA", "snB"}, + }, } auditMsg := auditmsgmod.NewMockModule(ctrl) node := nodemod.NewMockModule(ctrl) @@ -289,11 +301,13 @@ func TestTick_AttachedProofResultProviderIsDrainedAndForwarded(t *testing.T) { client.EXPECT().AuditMsg().AnyTimes().Return(auditMsg) client.EXPECT().SuperNode().AnyTimes().Return(sn) client.EXPECT().Node().AnyTimes().Return(node) + sn.EXPECT().GetSupernodeWithLatestAddress(gomock.Any(), "snA").Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1"}, nil) + sn.EXPECT().GetSupernodeWithLatestAddress(gomock.Any(), "snB").Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1"}, nil) provider := &stubProofResultProvider{ results: []*audittypes.StorageProofResult{ - {TargetSupernodeAccount: "snA", TicketId: "ticket-1", TranscriptHash: "hash-1"}, - {TargetSupernodeAccount: "snB", TicketId: "ticket-2", TranscriptHash: "hash-2"}, + {ChallengerSupernodeAccount: identity, TargetSupernodeAccount: "snA", TicketId: "ticket-1", TranscriptHash: "hash-1"}, + {ChallengerSupernodeAccount: identity, TargetSupernodeAccount: "snB", TicketId: "ticket-2", TranscriptHash: "hash-2"}, }, } @@ -416,9 +430,10 @@ func TestTick_SubmitFailureRequeuesProofResults(t *testing.T) { sn.EXPECT().GetSupernodeWithLatestAddress(gomock.Any(), "snA").AnyTimes().Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1:4444"}, nil) drained := []*audittypes.StorageProofResult{{ - TargetSupernodeAccount: "snA", - TicketId: "ticket-14", - TranscriptHash: "hash-14", + ChallengerSupernodeAccount: identity, + TargetSupernodeAccount: "snA", + TicketId: "ticket-14", + TranscriptHash: "hash-14", }} provider := &stubProofResultProvider{results: drained} @@ -472,9 +487,10 @@ func TestTick_DuplicateReportErrorDoesNotRequeue(t *testing.T) { sn.EXPECT().GetSupernodeWithLatestAddress(gomock.Any(), "snA").AnyTimes().Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1:4444"}, nil) provider := &stubProofResultProvider{results: []*audittypes.StorageProofResult{{ - TargetSupernodeAccount: "snA", - TicketId: "ticket-15", - TranscriptHash: "hash-15", + ChallengerSupernodeAccount: identity, + TargetSupernodeAccount: "snA", + TicketId: "ticket-15", + TranscriptHash: "hash-15", }}} // Match the chain phrase from lumera x/audit/v1/keeper/msg_submit_epoch_report.go:142. @@ -523,7 +539,7 @@ func TestTick_FULLModeIncompleteStorageProofCoverageSkipsSubmitAndRequeues(t *te auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) provider := &stubProofResultProvider{results: []*audittypes.StorageProofResult{ - {TargetSupernodeAccount: "snA", BucketType: audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, TicketId: "ticket-recent", TranscriptHash: "hash-recent"}, + {ChallengerSupernodeAccount: identity, TargetSupernodeAccount: "snA", BucketType: audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, TicketId: "ticket-recent", TranscriptHash: "hash-recent"}, }} svc, err := NewService(identity, client, kr, keyName, "", "") diff --git a/supernode/storage_challenge/lep6_client_factory.go b/supernode/storage_challenge/lep6_client_factory.go index 12f30521..66ba9c4f 100644 --- a/supernode/storage_challenge/lep6_client_factory.go +++ b/supernode/storage_challenge/lep6_client_factory.go @@ -74,26 +74,33 @@ func (f *secureSupernodeClientFactory) ensureClient() error { // Dial resolves the peer's chain-registered address and opens a secure // gRPC connection. The returned SupernodeCompoundClient holds onto the // underlying *grpc.ClientConn and closes it on Close(). -func (f *secureSupernodeClientFactory) Dial(ctx context.Context, target string) (SupernodeCompoundClient, error) { +func (f *secureSupernodeClientFactory) Dial(ctx context.Context, logicalTarget, currentTarget string) (SupernodeCompoundClient, error) { if err := f.ensureClient(); err != nil { return nil, err } - info, err := f.lumera.SuperNode().GetSupernodeWithLatestAddress(ctx, target) + logicalTarget = strings.TrimSpace(logicalTarget) + currentTarget = strings.TrimSpace(currentTarget) + if logicalTarget == "" || currentTarget == "" { + return nil, fmt.Errorf("logical and current target accounts are required") + } + info, err := f.lumera.SuperNode().GetSupernodeWithLatestAddress(ctx, currentTarget) if err != nil || info == nil { - return nil, fmt.Errorf("resolve target %q: %w", target, err) + return nil, fmt.Errorf("resolve current target %q (logical %q): %w", currentTarget, logicalTarget, err) } raw := strings.TrimSpace(info.LatestAddress) if raw == "" { - return nil, fmt.Errorf("no address for target %q", target) + return nil, fmt.Errorf("no address for current target %q", currentTarget) } host, port, ok := netutil.ParseHostAndPort(raw, int(f.defaultPort)) if !ok || strings.TrimSpace(host) == "" { - return nil, fmt.Errorf("invalid address %q for target %q", raw, target) + return nil, fmt.Errorf("invalid address %q for current target %q", raw, currentTarget) } addr := net.JoinHostPort(strings.TrimSpace(host), strconv.Itoa(port)) - conn, err := f.grpcClient.Connect(ctx, fmt.Sprintf("%s@%s", strings.TrimSpace(target), addr), f.grpcOpts) + // ALTS authenticates the live owner. The logical target remains confined to + // the challenge/transcript payload. + conn, err := f.grpcClient.Connect(ctx, fmt.Sprintf("%s@%s", currentTarget, addr), f.grpcOpts) if err != nil { - return nil, fmt.Errorf("dial target %q: %w", target, err) + return nil, fmt.Errorf("dial current target %q (logical %q): %w", currentTarget, logicalTarget, err) } return &secureCompoundClient{conn: conn, client: supernode.NewStorageChallengeServiceClient(conn)}, nil } diff --git a/supernode/storage_challenge/lep6_dispatch.go b/supernode/storage_challenge/lep6_dispatch.go index 21bfac6d..549a7480 100644 --- a/supernode/storage_challenge/lep6_dispatch.go +++ b/supernode/storage_challenge/lep6_dispatch.go @@ -18,6 +18,7 @@ import ( snkeyring "github.com/LumeraProtocol/supernode/v2/pkg/keyring" "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + auditmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/audit" lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge" "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge/deterministic" @@ -63,7 +64,7 @@ type SupernodeCompoundClient interface { // supernode secure gRPC dialer (see service.go::callGetSliceProof for the // reference implementation). type SupernodeClientFactory interface { - Dial(ctx context.Context, targetSupernodeAccount string) (SupernodeCompoundClient, error) + Dial(ctx context.Context, logicalTargetAccount, currentTargetAccount string) (SupernodeCompoundClient, error) } // CascadeMetaProvider returns the cascade metadata for a ticket. The @@ -221,8 +222,11 @@ func (d *LEP6Dispatcher) DispatchEpoch(ctx context.Context, epochID uint64) erro if err != nil || assigned == nil { return fmt.Errorf("lep6 dispatch: get assigned targets: %w", err) } - targets := assigned.TargetSupernodeAccounts - if len(targets) == 0 { + assignment, err := auditmod.ResolveAssignedTargets(assigned, epochID) + if err != nil { + return fmt.Errorf("lep6 dispatch: invalid assigned targets: %w", err) + } + if len(assignment.Targets) == 0 { logtrace.Debug(ctx, "lep6 dispatch: no targets assigned this epoch", logtrace.Fields{ "epoch_id": epochID, "mode": mode.String(), @@ -246,21 +250,20 @@ func (d *LEP6Dispatcher) DispatchEpoch(ctx context.Context, epochID uint64) erro logtrace.Info(ctx, "lep6 dispatch: starting epoch", logtrace.Fields{ "epoch_id": epochID, "mode": mode.String(), - "targets": len(targets), + "targets": len(assignment.Targets), }) d.mu.Lock() defer d.mu.Unlock() - for _, target := range targets { - target = strings.TrimSpace(target) - if target == "" || target == d.self { + for _, target := range assignment.Targets { + if target.LogicalAccount == assignment.ReporterAccount { continue } - if err := d.dispatchTarget(ctx, epochID, anchor, params, currentHeight, target); err != nil { + if err := d.dispatchTarget(ctx, epochID, anchor, params, currentHeight, assignment.ReporterAccount, target); err != nil { logtrace.Warn(ctx, "lep6 dispatch: target loop error", logtrace.Fields{ "epoch_id": epochID, - "target": target, + "target": target.LogicalAccount, "error": err.Error(), }) } @@ -274,15 +277,17 @@ func (d *LEP6Dispatcher) dispatchTarget( anchor audittypes.EpochAnchor, params audittypes.Params, currentHeight int64, - target string, + reporter string, + target auditmod.AssignedTarget, ) error { - tickets, err := d.tickets.TicketsForTarget(ctx, target) + logicalTarget := target.LogicalAccount + tickets, err := d.tickets.TicketsForTarget(ctx, logicalTarget) if err != nil { // Treat as transient; emit no-eligible for both buckets so the // chain still sees this epoch covered. lep6metrics.SetNoTicketProviderActive(true) logtrace.Warn(ctx, "lep6 dispatch: ticket provider error", logtrace.Fields{ - "epoch_id": epochID, "target": target, "error": err.Error(), + "epoch_id": epochID, "target": logicalTarget, "error": err.Error(), }) tickets = nil } @@ -302,20 +307,20 @@ func (d *LEP6Dispatcher) dispatchTarget( if len(eligibleIDs) == 0 { lep6metrics.SetNoTicketProviderActive(true) - d.appendNoEligible(ctx, d.buffer, epochID, anchor, target, bucket, "") + d.appendNoEligible(ctx, d.buffer, epochID, anchor, reporter, logicalTarget, bucket, "") continue } - ticketID := deterministic.SelectTicketForBucket(eligibleIDs, nil, anchor.Seed, target, bucket) + ticketID := deterministic.SelectTicketForBucket(eligibleIDs, nil, anchor.Seed, logicalTarget, bucket) if ticketID == "" { lep6metrics.SetNoTicketProviderActive(true) - d.appendNoEligible(ctx, d.buffer, epochID, anchor, target, bucket, "") + d.appendNoEligible(ctx, d.buffer, epochID, anchor, reporter, logicalTarget, bucket, "") continue } - if err := d.dispatchTicket(ctx, d.buffer, epochID, anchor, params, target, bucket, ticketID); err != nil { + if err := d.dispatchTicket(ctx, d.buffer, epochID, anchor, params, reporter, target, bucket, ticketID); err != nil { logtrace.Warn(ctx, "lep6 dispatch: ticket loop error", logtrace.Fields{ - "epoch_id": epochID, "target": target, "ticket": ticketID, "error": err.Error(), + "epoch_id": epochID, "target": logicalTarget, "ticket": ticketID, "error": err.Error(), }) } } @@ -340,6 +345,7 @@ func (d *LEP6Dispatcher) appendNoEligible( buf *Buffer, epochID uint64, anchor audittypes.EpochAnchor, + reporter string, target string, bucket audittypes.StorageProofBucketType, selectedTicketIDForLog string, @@ -367,7 +373,7 @@ func (d *LEP6Dispatcher) appendNoEligible( transcriptHashHex, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ EpochID: epochID, - ChallengerSupernodeAccount: d.self, + ChallengerSupernodeAccount: reporter, TargetSupernodeAccount: target, TicketID: "", Bucket: bucket, @@ -397,7 +403,7 @@ func (d *LEP6Dispatcher) appendNoEligible( lep6metrics.IncDispatchResult(audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET.String()) buf.Append(epochID, &audittypes.StorageProofResult{ TargetSupernodeAccount: target, - ChallengerSupernodeAccount: d.self, + ChallengerSupernodeAccount: reporter, BucketType: bucket, ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET, TranscriptHash: transcriptHashHex, @@ -435,10 +441,12 @@ func (d *LEP6Dispatcher) dispatchTicket( epochID uint64, anchor audittypes.EpochAnchor, params audittypes.Params, - target string, + reporter string, + target auditmod.AssignedTarget, bucket audittypes.StorageProofBucketType, ticketID string, ) error { + logicalTarget := target.LogicalAccount meta, fileSizeKbs, err := d.meta.GetCascadeMetadata(ctx, ticketID) if err != nil || meta == nil { if cerr := ctx.Err(); cerr != nil { @@ -451,12 +459,12 @@ func (d *LEP6Dispatcher) dispatchTicket( indexCount, _ := storagechallenge.ResolveArtifactCount(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX) symbolCount, _ := storagechallenge.ResolveArtifactCount(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL) - class := deterministic.SelectArtifactClass(anchor.Seed, target, ticketID, indexCount, symbolCount) + class := deterministic.SelectArtifactClass(anchor.Seed, logicalTarget, ticketID, indexCount, symbolCount) if class == audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED { // LEP-6 review H6 + L5: rolled class is empty for this ticket. Emit // NO_ELIGIBLE_TICKET (no cross-class swap) and surface the selected // ticket id in structured logs only — the chain row keeps ticket_id="". - d.appendNoEligible(ctx, buf, epochID, anchor, target, bucket, ticketID) + d.appendNoEligible(ctx, buf, epochID, anchor, reporter, logicalTarget, bucket, ticketID) return nil } @@ -467,7 +475,7 @@ func (d *LEP6Dispatcher) dispatchTicket( case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: artifactCount = symbolCount } - ordinal, err := deterministic.SelectArtifactOrdinal(anchor.Seed, target, ticketID, class, artifactCount) + ordinal, err := deterministic.SelectArtifactOrdinal(anchor.Seed, logicalTarget, ticketID, class, artifactCount) if err != nil { lep6metrics.IncDispatchInternalFailure("select_ordinal") return fmt.Errorf("select ordinal: %w", err) @@ -524,7 +532,7 @@ func (d *LEP6Dispatcher) dispatchTicket( k = deterministic.LEP6CompoundRangesPerArtifact } - offsets, err := deterministic.ComputeMultiRangeOffsets(anchor.Seed, target, ticketID, class, ordinal, artifactSize, rangeLen, k) + offsets, err := deterministic.ComputeMultiRangeOffsets(anchor.Seed, logicalTarget, ticketID, class, ordinal, artifactSize, rangeLen, k) if err != nil { lep6metrics.IncDispatchInternalFailure("compute_offsets") return fmt.Errorf("compute offsets: %w", err) @@ -534,21 +542,21 @@ func (d *LEP6Dispatcher) dispatchTicket( ranges[i] = &supernode.ByteRange{Start: off, End: off + rangeLen} } - derivHash, err := deterministic.DerivationInputHash(anchor.Seed, target, ticketID, class, ordinal, offsets, rangeLen) + derivHash, err := deterministic.DerivationInputHash(anchor.Seed, logicalTarget, ticketID, class, ordinal, offsets, rangeLen) if err != nil { lep6metrics.IncDispatchInternalFailure("derivation_hash") return fmt.Errorf("derivation input hash: %w", err) } - challengeID := deriveCompoundChallengeID(anchor.Seed, epochID, target, ticketID, class, ordinal) + challengeID := deriveCompoundChallengeID(anchor.Seed, epochID, logicalTarget, ticketID, class, ordinal) req := &supernode.GetCompoundProofRequest{ ChallengeId: challengeID, EpochId: epochID, Seed: anchor.Seed, TicketId: ticketID, - TargetSupernodeAccount: target, - ChallengerAccount: d.self, + TargetSupernodeAccount: logicalTarget, + ChallengerAccount: reporter, ArtifactClass: uint32(class), ArtifactOrdinal: ordinal, ArtifactCount: artifactCount, @@ -558,9 +566,9 @@ func (d *LEP6Dispatcher) dispatchTicket( Ranges: ranges, } - conn, err := d.supernodeClient.Dial(ctx, target) + conn, err := d.supernodeClient.Dial(ctx, logicalTarget, target.CurrentAccount) if err != nil { - d.appendFail(ctx, buf, epochID, target, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, classifyProofFailure(err, "dial"), fmt.Sprintf("dial: %v", err)) + d.appendFail(ctx, buf, epochID, reporter, logicalTarget, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, classifyProofFailure(err, "dial"), fmt.Sprintf("dial: %v", err)) return nil } defer func() { _ = conn.Close() }() @@ -573,33 +581,33 @@ func (d *LEP6Dispatcher) dispatchTicket( } else if resp != nil && resp.Error != "" { reason = resp.Error } - d.appendFail(ctx, buf, epochID, target, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, classifyProofFailure(err, reason), reason) + d.appendFail(ctx, buf, epochID, reporter, logicalTarget, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, classifyProofFailure(err, reason), reason) return nil } // Local validation: range count + per-range size, and proof hash recompute. if len(resp.RangeBytes) != k { - d.appendFail(ctx, buf, epochID, target, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, fmt.Sprintf("range count mismatch: got %d want %d", len(resp.RangeBytes), k)) + d.appendFail(ctx, buf, epochID, reporter, logicalTarget, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, fmt.Sprintf("range count mismatch: got %d want %d", len(resp.RangeBytes), k)) return nil } hasher := blake3.New(32, nil) for i, b := range resp.RangeBytes { if uint64(len(b)) != rangeLen { - d.appendFail(ctx, buf, epochID, target, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, fmt.Sprintf("range[%d] size %d != %d", i, len(b), rangeLen)) + d.appendFail(ctx, buf, epochID, reporter, logicalTarget, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, fmt.Sprintf("range[%d] size %d != %d", i, len(b), rangeLen)) return nil } _, _ = hasher.Write(b) } gotHash := hex.EncodeToString(hasher.Sum(nil)) if !strings.EqualFold(gotHash, resp.ProofHashHex) { - d.appendFail(ctx, buf, epochID, target, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, fmt.Sprintf("proof hash mismatch: local=%s remote=%s", gotHash, resp.ProofHashHex)) + d.appendFail(ctx, buf, epochID, reporter, logicalTarget, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, fmt.Sprintf("proof hash mismatch: local=%s remote=%s", gotHash, resp.ProofHashHex)) return nil } transcriptHashHex, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ EpochID: epochID, - ChallengerSupernodeAccount: d.self, - TargetSupernodeAccount: target, + ChallengerSupernodeAccount: reporter, + TargetSupernodeAccount: logicalTarget, TicketID: ticketID, Bucket: bucket, ArtifactClass: class, @@ -624,8 +632,8 @@ func (d *LEP6Dispatcher) dispatchTicket( lep6metrics.IncDispatchResult(audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS.String()) buf.Append(epochID, &audittypes.StorageProofResult{ - TargetSupernodeAccount: target, - ChallengerSupernodeAccount: d.self, + TargetSupernodeAccount: logicalTarget, + ChallengerSupernodeAccount: reporter, TicketId: ticketID, BucketType: bucket, ArtifactClass: class, @@ -653,6 +661,7 @@ func (d *LEP6Dispatcher) appendFail( ctx context.Context, buf *Buffer, epochID uint64, + reporter string, target string, bucket audittypes.StorageProofBucketType, ticketID string, @@ -666,7 +675,7 @@ func (d *LEP6Dispatcher) appendFail( ) { transcriptHashHex, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ EpochID: epochID, - ChallengerSupernodeAccount: d.self, + ChallengerSupernodeAccount: reporter, TargetSupernodeAccount: target, TicketID: ticketID, Bucket: bucket, @@ -694,7 +703,7 @@ func (d *LEP6Dispatcher) appendFail( lep6metrics.IncDispatchResult(resultClass.String()) buf.Append(epochID, &audittypes.StorageProofResult{ TargetSupernodeAccount: target, - ChallengerSupernodeAccount: d.self, + ChallengerSupernodeAccount: reporter, TicketId: ticketID, BucketType: bucket, ArtifactClass: class, diff --git a/supernode/storage_challenge/lep6_dispatch_test.go b/supernode/storage_challenge/lep6_dispatch_test.go index 41e84b78..a4cc9549 100644 --- a/supernode/storage_challenge/lep6_dispatch_test.go +++ b/supernode/storage_challenge/lep6_dispatch_test.go @@ -56,6 +56,14 @@ func (s *dispatchAuditModule) GetCurrentEpochAnchor(ctx context.Context) (*audit return &audittypes.QueryCurrentEpochAnchorResponse{}, nil } func (s *dispatchAuditModule) GetAssignedTargets(ctx context.Context, supernodeAccount string, epochID uint64) (*audittypes.QueryAssignedTargetsResponse, error) { + if s.assigned != nil && s.assigned.ReporterSupernodeAccount == "" { + s.assigned.EpochId = epochID + s.assigned.ReporterSupernodeAccount = supernodeAccount + s.assigned.TargetAccountMappings = make([]audittypes.AccountIdentityMapping, len(s.assigned.TargetSupernodeAccounts)) + for i, target := range s.assigned.TargetSupernodeAccounts { + s.assigned.TargetAccountMappings[i] = audittypes.AccountIdentityMapping{LogicalAccount: target, CurrentAccount: target} + } + } return s.assigned, nil } func (s *dispatchAuditModule) GetEpochReport(ctx context.Context, epochID uint64, supernodeAccount string) (*audittypes.QueryEpochReportResponse, error) { @@ -142,9 +150,11 @@ func (s *stubCompoundClient) Close() error { return nil } type stubFactory struct { client *stubCompoundClient err error + dials [][2]string } -func (s *stubFactory) Dial(_ context.Context, _ string) (SupernodeCompoundClient, error) { +func (s *stubFactory) Dial(_ context.Context, logical, current string) (SupernodeCompoundClient, error) { + s.dials = append(s.dials, [2]string{logical, current}) if s.err != nil { return nil, s.err } @@ -230,7 +240,7 @@ func TestAppendNoEligiblePreservedWhenOnlySelectedTicketExists(t *testing.T) { d, buf := newDispatcher(t, audit, &stubFactory{}, NoTicketProvider{}, stubMetaProvider{}) anchor := makeAnchor(9, 1000, "target-1") - d.appendNoEligible(context.Background(), buf, 9, anchor, "target-1", audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, "ticket-existing") + d.appendNoEligible(context.Background(), buf, 9, anchor, "reporter-1", "target-1", audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, "ticket-existing") results := buf.CollectResults(9) require.Len(t, results, 1, "selected ticket alone is not a chain transcript-history conflict; H6 class-roll fallback still emits NO_ELIGIBLE") @@ -248,7 +258,7 @@ func TestAppendNoEligibleSuppressedWhenBufferedEligibleResultExists(t *testing.T ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, }) - d.appendNoEligible(context.Background(), buf, 10, anchor, "target-1", bucket, "") + d.appendNoEligible(context.Background(), buf, 10, anchor, "reporter-1", "target-1", bucket, "") results := buf.CollectResults(10) require.Len(t, results, 1) @@ -350,14 +360,21 @@ func TestDispatchEpoch_GetCompoundProofError_EmitsFailClass(t *testing.T) { const epochID uint64 = 17 // EpochEndHeight=200, ticket anchor=100 → currentHeight-anchor=100 < 300 → // RECENT bucket eligible. - anchor := makeAnchor(epochID, 200, "sn-target") + anchor := makeAnchor(epochID, 200, "target-A") audit := &dispatchAuditModule{ - params: &audittypes.QueryParamsResponse{Params: defaultParams(audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_SHADOW)}, - anchor: &audittypes.QueryEpochAnchorResponse{Anchor: anchor}, - assigned: &audittypes.QueryAssignedTargetsResponse{TargetSupernodeAccounts: []string{"sn-target"}}, + params: &audittypes.QueryParamsResponse{Params: defaultParams(audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_SHADOW)}, + anchor: &audittypes.QueryEpochAnchorResponse{Anchor: anchor}, + assigned: &audittypes.QueryAssignedTargetsResponse{ + EpochId: epochID, + ReporterSupernodeAccount: "reporter-A", + TargetSupernodeAccounts: []string{"target-A"}, + TargetAccountMappings: []audittypes.AccountIdentityMapping{{ + LogicalAccount: "target-A", CurrentAccount: "target-B", + }}, + }, } tickets := stubTicketProvider{tickets: map[string][]TicketDescriptor{ - "sn-target": {{TicketID: "tkt-rpc-fail", AnchorBlock: 100}}, + "target-A": {{TicketID: "tkt-rpc-fail", AnchorBlock: 100}}, }} // Cascade meta: SYMBOL-only with one id; artifact_size big enough for 4*256. meta := stubMetaProvider{ @@ -369,6 +386,10 @@ func TestDispatchEpoch_GetCompoundProofError_EmitsFailClass(t *testing.T) { d, buf := newDispatcher(t, audit, factory, tickets, meta) require.NoError(t, d.DispatchEpoch(context.Background(), epochID)) + require.Equal(t, [][2]string{{"target-A", "target-B"}}, factory.dials, "live routing must use the current target while retaining its logical identity") + require.Len(t, factory.client.requests, 1) + require.Equal(t, "reporter-A", factory.client.requests[0].ChallengerAccount) + require.Equal(t, "target-A", factory.client.requests[0].TargetSupernodeAccount, "recipient payload must carry the epoch-logical target") results := buf.CollectResults(epochID) require.NotEmpty(t, results) // Expect a FAIL class for the RECENT bucket (single eligible ticket) and @@ -378,7 +399,37 @@ func TestDispatchEpoch_GetCompoundProofError_EmitsFailClass(t *testing.T) { switch r.ResultClass { case audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT: sawFail = true + require.Equal(t, "reporter-A", r.ChallengerSupernodeAccount) + require.Equal(t, "target-A", r.TargetSupernodeAccount) require.Contains(t, r.Details, "rpc unavailable") + + logicalTranscript, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ + EpochID: epochID, + ChallengerSupernodeAccount: "reporter-A", + TargetSupernodeAccount: "target-A", + TicketID: r.TicketId, + Bucket: r.BucketType, + ArtifactClass: r.ArtifactClass, + ArtifactOrdinal: r.ArtifactOrdinal, + ArtifactKey: r.ArtifactKey, + DerivationInputHash: r.DerivationInputHash, + }) + require.NoError(t, err) + require.Equal(t, logicalTranscript, r.TranscriptHash, "transcript must remain bound to epoch-logical identities") + + currentTranscript, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ + EpochID: epochID, + ChallengerSupernodeAccount: d.self, + TargetSupernodeAccount: "target-B", + TicketID: r.TicketId, + Bucket: r.BucketType, + ArtifactClass: r.ArtifactClass, + ArtifactOrdinal: r.ArtifactOrdinal, + ArtifactKey: r.ArtifactKey, + DerivationInputHash: r.DerivationInputHash, + }) + require.NoError(t, err) + require.NotEqual(t, currentTranscript, r.TranscriptHash, "current routing identities must not leak into the transcript") case audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET: sawNoEligible = true } diff --git a/supernode/storage_challenge/lep6_recheck.go b/supernode/storage_challenge/lep6_recheck.go index 3b575fef..8bcde349 100644 --- a/supernode/storage_challenge/lep6_recheck.go +++ b/supernode/storage_challenge/lep6_recheck.go @@ -5,6 +5,7 @@ import ( "fmt" audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + auditmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/audit" "github.com/LumeraProtocol/supernode/v2/supernode/recheck" ) @@ -46,10 +47,23 @@ func (d *LEP6Dispatcher) Recheck(ctx context.Context, c recheck.Candidate) (rech return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: epoch anchor not yet available for epoch %d", c.EpochID) } + assigned, err := d.client.Audit().GetAssignedTargets(ctx, d.self, c.EpochID) + if err != nil { + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: get assigned targets: %w", err) + } + assignment, err := auditmod.ResolveAssignedTargets(assigned, c.EpochID) + if err != nil { + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: invalid assigned targets: %w", err) + } + target, ok := assignedTargetByLogicalAccount(assignment.Targets, c.TargetAccount) + if !ok { + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: target %s is not assigned in epoch %d", c.TargetAccount, c.EpochID) + } + // Per-call ephemeral buffer: dispatchTicket writes here, dispatcher's // shared buffer is left alone. No global lock held during the RPC. tmp := NewBuffer() - if err := d.dispatchTicket(ctx, tmp, c.EpochID, anchorResp.Anchor, params, c.TargetAccount, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECHECK, c.TicketID); err != nil { + if err := d.dispatchTicket(ctx, tmp, c.EpochID, anchorResp.Anchor, params, assignment.ReporterAccount, target, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECHECK, c.TicketID); err != nil { return recheck.RecheckResult{}, err } results := tmp.CollectResults(c.EpochID) @@ -65,3 +79,12 @@ func (d *LEP6Dispatcher) Recheck(ctx context.Context, c recheck.Candidate) (rech } return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: no result emitted for epoch=%d ticket=%s target=%s", c.EpochID, c.TicketID, c.TargetAccount) } + +func assignedTargetByLogicalAccount(targets []auditmod.AssignedTarget, logical string) (auditmod.AssignedTarget, bool) { + for _, target := range targets { + if target.LogicalAccount == logical { + return target, true + } + } + return auditmod.AssignedTarget{}, false +}