diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index 41e49b25..7b6d0cf7 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -1,11 +1,11 @@ name: Setup Environment description: Sets up Go (dynamically from go.mod) and installs system dependencies -inputs: {} -# bust_lumera_retag: -# description: "One-time: remove lumera sums after retag" -# required: false -# default: 'false' +inputs: + bust_lumera_retag: + description: "One-time: remove cached Lumera module artifacts after a retag/checksum refresh" + required: false + default: 'false' outputs: go-version: description: "Go version parsed from go.mod" @@ -33,17 +33,14 @@ runs: sudo apt-get update sudo apt-get install -y libwebp-dev make - # - name: One-time reset retagged lumera checksums - # if: ${{ inputs.bust_lumera_retag == 'true' }} - # shell: bash - # run: | - # echo "Busting go.sum entries for github.com/LumeraProtocol/lumera v1.11.0-rc (one-time)" - # # Remove stale checksums in all local modules - # find . -name 'go.sum' -maxdepth 3 -print0 | xargs -0 -I{} sed -i \ - # '/github.com\/LumeraProtocol\/lumera v1.11.0-rc/d' {} - # # Clear module/build caches to avoid cached zips - # go clean -modcache || true - # rm -rf "$(go env GOCACHE)" || true + - name: Bust cached Lumera module artifacts + if: ${{ inputs.bust_lumera_retag == 'true' }} + shell: bash + run: | + echo "Busting cached Lumera module artifacts before go mod download" + go clean -modcache || true + rm -rf "$(go env GOCACHE)" || true + rm -rf "$(go env GOPATH)/pkg/mod/cache/download/github.com/!lumera!protocol/lumera" || true - name: Set Go Private Modules shell: bash diff --git a/.github/workflows/build&release.yml b/.github/workflows/build&release.yml index cd99058b..74f9483c 100644 --- a/.github/workflows/build&release.yml +++ b/.github/workflows/build&release.yml @@ -27,8 +27,8 @@ jobs: - name: Setup Go and dependencies uses: ./.github/actions/setup-env - # with: - # bust_lumera_retag: 'true' + with: + bust_lumera_retag: 'true' - name: Build binaries run: | @@ -74,8 +74,8 @@ jobs: - name: Setup Go and dependencies uses: ./.github/actions/setup-env - # with: - # bust_lumera_retag: 'true' + with: + bust_lumera_retag: 'true' - name: Prepare Release Variables id: vars diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 26796204..d7d6e2d9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,8 +17,8 @@ jobs: uses: actions/checkout@v6.0.1 - name: Setup Go and system deps uses: ./.github/actions/setup-env - # with: - # bust_lumera_retag: 'true' + with: + bust_lumera_retag: 'true' - name: Go mod tidy run: go mod tidy @@ -35,8 +35,8 @@ jobs: - name: Setup Go and system deps uses: ./.github/actions/setup-env - # with: - # bust_lumera_retag: 'true' + with: + bust_lumera_retag: 'true' - name: Go mod tidy run: go mod tidy @@ -54,8 +54,8 @@ jobs: - name: Setup Go and system deps uses: ./.github/actions/setup-env - # with: - # bust_lumera_retag: 'true' + with: + bust_lumera_retag: 'true' - name: Go mod tidy run: go mod tidy @@ -70,6 +70,28 @@ jobs: - name: Run cascade e2e tests run: make test-cascade + lep6-e2e-tests: + name: lep6-e2e-tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6.0.1 + + - name: Setup Go and system deps + uses: ./.github/actions/setup-env + with: + bust_lumera_retag: 'true' + + - name: Go mod tidy + run: go mod tidy + + - name: Install Lumera + run: make install-lumera + + - name: Run LEP-6 e2e tests + run: make test-lep6 + # sn-manager-e2e-tests: # name: sn-manager-e2e-tests # runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 39296abf..9d32cd07 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ go.work go.work.sum tests/system/testnet tests/system/**/supernode-data* +tests/system/supernode-lep6-data*/ +.lep6-wip-backup/ tests/system/data tests/system/1 # env file diff --git a/Makefile b/Makefile index 52d9589e..26de039d 100644 --- a/Makefile +++ b/Makefile @@ -120,9 +120,9 @@ release: ################################################### ### Tests and Simulation ### ################################################### -.PHONY: test-e2e test-unit test-integration test-system test-cascade test-sn-manager -.PHONY: install-lumera setup-supernodes system-test-setup install-deps -.PHONY: gen-cascade gen-supernode +.PHONY: test-e2e test-unit test-integration test-system test-cascade test-lep6 test-sn-manager +.PHONY: install-lumera setup-supernodes setup-lep6-supernodes system-test-setup install-deps +.PHONY: gen-cascade gen-supernode audit-mod-clean lep6-reset-dedup lep6-validate-config test-unit: ${GO} test -v ./... @@ -152,16 +152,22 @@ gen-supernode: --grpc-gateway_out=gen \ --grpc-gateway_opt=paths=source_relative \ --openapiv2_out=gen \ - proto/supernode/service.proto proto/supernode/status.proto proto/supernode/storage_challenge.proto + proto/supernode/service.proto proto/supernode/status.proto proto/supernode/storage_challenge.proto proto/supernode/self_healing.proto # Define the paths SUPERNODE_SRC=supernode/main.go DATA_DIR=tests/system/supernode-data1 DATA_DIR2=tests/system/supernode-data2 DATA_DIR3=tests/system/supernode-data3 +LEP6_DATA_DIR=tests/system/supernode-lep6-data1 +LEP6_DATA_DIR2=tests/system/supernode-lep6-data2 +LEP6_DATA_DIR3=tests/system/supernode-lep6-data3 CONFIG_FILE=tests/system/config.test-1.yml CONFIG_FILE2=tests/system/config.test-2.yml CONFIG_FILE3=tests/system/config.test-3.yml +LEP6_CONFIG_FILE=tests/system/config.lep6-1.yml +LEP6_CONFIG_FILE2=tests/system/config.lep6-2.yml +LEP6_CONFIG_FILE3=tests/system/config.lep6-3.yml # Setup script SETUP_SCRIPT=tests/scripts/setup-supernodes.sh @@ -186,6 +192,12 @@ setup-supernodes: @chmod +x $(SETUP_SCRIPT) @bash $(SETUP_SCRIPT) all $(SUPERNODE_SRC) $(DATA_DIR) $(CONFIG_FILE) $(DATA_DIR2) $(CONFIG_FILE2) $(DATA_DIR3) $(CONFIG_FILE3) +setup-lep6-supernodes: + @echo "Setting up isolated LEP-6 supernode environments..." + @rm -rf tests/system/heal-staging + @chmod +x $(SETUP_SCRIPT) + @bash $(SETUP_SCRIPT) all $(SUPERNODE_SRC) $(LEP6_DATA_DIR) $(LEP6_CONFIG_FILE) $(LEP6_DATA_DIR2) $(LEP6_CONFIG_FILE2) $(LEP6_DATA_DIR3) $(LEP6_CONFIG_FILE3) + # Complete system test setup (Lumera + Supernodes) system-test-setup: install-lumera setup-supernodes @echo "System test environment setup complete." @@ -201,6 +213,31 @@ test-cascade: @echo "Running cascade e2e tests..." @cd tests/system && ${GO} mod tidy && ${GO} test -tags=system_test -v -run TestCascadeE2E . +# Run LEP-6 e2e tests only against the real lumerad/local-chain system harness. +# The runtime test uses isolated supernode-lep6-data* directories so per-node +# SQLite history/dedup state is not shared with Cascade fixtures or other nodes. +test-lep6: setup-lep6-supernodes + @echo "Running LEP-6 e2e tests..." + @cd tests/system && ${GO} mod tidy && ${GO} test -tags=system_test -timeout=900s -v -run '^TestLEP6' . + +# Validate LEP-6 local config/default/fixture coverage without starting a network. +lep6-validate-config: + @echo "Validating LEP-6 supernode config fixtures..." + @${GO} test ./supernode/config -run 'TestLoadConfig_LEP6|TestCreateDefaultConfig_IncludesExplicitLEP6Blocks|TestSystemConfigFixturesIncludeLEP6' -count=1 + +# Recover from stale Lumera module checksum/cache issues during local PR-6 work. +audit-mod-clean: + @echo "Cleaning Go module cache and re-resolving modules..." + @${GO} clean -modcache + @${GO} mod download + +# Reset local LEP-6 dedup/reconciliation tables. Requires DB=/absolute/path/to/local.db. +lep6-reset-dedup: + @if [ -z "$(DB)" ]; then echo "DB=/absolute/path/to/local.db is required"; exit 2; fi + @test -f "$(DB)" || (echo "DB does not exist: $(DB)"; exit 2) + @echo "Resetting LEP-6 local dedup tables in $(DB): heal_claims_submitted, heal_verifications_submitted, storage_recheck_submissions, recheck_attempt_failures" + @sqlite3 "$(DB)" "DELETE FROM heal_claims_submitted; DELETE FROM heal_verifications_submitted; DELETE FROM storage_recheck_submissions; DELETE FROM recheck_attempt_failures;" + # Run sn-manager e2e tests only test-sn-manager: @echo "Running sn-manager e2e tests..." diff --git a/docs/lep6-supernode-runbook.md b/docs/lep6-supernode-runbook.md new file mode 100644 index 00000000..1662ce2e --- /dev/null +++ b/docs/lep6-supernode-runbook.md @@ -0,0 +1,111 @@ +# LEP-6 Supernode Release Runbook + +This runbook covers the Supernode-side LEP-6 storage-truth enforcement support introduced across the LEP-6 PR stack and finalized in PR-6. + +## Scope + +Supernode LEP-6 provides runtime support for Lumera `v1.12.0` audit/storage-truth APIs: + +- storage challenge ticket discovery and transcript/evidence submission; +- storage recheck candidate discovery, local retry budget, and `MsgSubmitStorageRecheckEvidence` submission; +- self-healing heal-op dispatch, healer claim submission, verifier attestation submission, and finalizer publication only after chain-verified heal success; +- repo-native in-process observability snapshots plus structured `logtrace` events. + +The chain remains the source of truth for heal-op scheduling, verifier assignment, verification quorum, rejected/failed/expired status, and scoring/probation changes. + +## Release prerequisites + +1. Supernode must depend on Lumera `v1.12.0` APIs. +2. Operators must run against a Lumera chain whose audit module includes LEP-6 storage-truth endpoints. +3. Supernode local SQLite storage must be writable; PR-6 adds local idempotency state for pending/submitted heal and recheck txs. +4. Existing Supernode status/log collection should be enabled so LEP-6 snapshot counters and structured logs are visible through the same operator workflow used by storage challenge, Cascade, and supernode metrics. + +## Local validation commands + +From the supernode repository root: + +```bash +export PATH=/home/openclaw/.local/go/bin:$PATH +go test $(go list ./... | grep -v '/tests') +``` + +For the real-chain LEP-6 system test: + +```bash +make system-test-setup +make test-lep6 +``` + +`make test-lep6` runs `tests/system/TestLEP6RealChainIntegration` using the same real `lumerad`/local-chain harness as Cascade e2e. It does not use chain mocks. + +## Observability + +LEP-6 uses the repo-native Supernode observability pattern: in-process atomic snapshots plus structured `logtrace` fields. PR-6 does **not** add a LEP-6-only Prometheus endpoint. + +LEP-6 snapshot signals include: + +- challenge dispatch results by chain result class; +- challenge dispatch throttling drops by reason; +- challenge dispatch epoch duration totals/counts by role; +- ticket discovery outcomes; +- no-ticket-provider-active state; +- recheck candidates discovered and current pending candidate gauge; +- recheck submissions by result class/result; +- recheck already-submitted dedupe count; +- recheck failure counts by stage; +- heal claims by result; +- heal claim reconciliation count; +- heal verifications by result/vote; +- heal verification already-recorded dedupe count; +- self-healing pending claim gauge; +- self-healing staging bytes gauge; +- finalizer publish count; +- finalizer cleanup count by terminal chain status. + +Suggested alerts/signals from snapshots/logs: + +- sustained heal-claim `submit_error` or `stage_error` increases; +- sustained heal-verification `submit_error` or `stage_error` increases; +- sustained recheck failure increases by stage; +- challenge dispatch throttling drops approaching the chain cap; +- no-ticket-provider-active remaining true after candidate-producing epochs; +- self-healing staging bytes increasing without matching finalizer publish/cleanup progress; +- rejected/failed/expired finalizer cleanup spikes after a release. + +## Operational behavior + +### Successful healing + +1. Chain schedules a heal-op and assigns a healer/verifiers. +2. Healer stages recovered data locally and pre-stages a local dedup row. +3. Healer submits `MsgClaimHealComplete`. +4. On chain acceptance, Supernode marks the local row as submitted. +5. Verifiers fetch and verify the staged manifest/hash, pre-stage local dedup rows, and submit `MsgSubmitHealVerification`. +6. Once chain marks the heal-op verified, the finalizer publishes the healed artifact to the P2P layer. + +Important: the healed file is not published as durable P2P recovery output before successful chain verification. + +### Rejected healing + +If verifier quorum rejects the heal, the chain marks the heal-op rejected/failed according to Lumera `v1.12.0` keeper rules. Supernode does not publish the healer output as recovered data. + +### Healer cannot heal / no-show + +If the healer cannot produce a valid manifest or misses the deadline, the chain eventually expires/fails the heal-op and applies LEP-6 scoring/probation rules. Supernode records errors and retry/backoff state locally where applicable, but does not override chain status. + +### Restart/idempotency + +PR-6 closes the submit-success/persist-crash window by pre-staging local pending rows before chain tx submission for: + +- heal claims; +- heal verifications; +- recheck evidence submissions. + +Pending rows dedup retries after restart; successful txs are marked submitted after chain acceptance. Submit failures remove the pending row so the operation can retry later. + +## Troubleshooting + +- If duplicate tx errors appear after restart, inspect local SQLite `status` values for LEP-6 pending/submitted tables and compare with chain heal/recheck state. +- If recheck candidates stop processing, inspect `recheck_attempt_failures`; failures expire after the configured TTL and successful submissions clear the failure budget. +- If LEP-6 counters are flat while work is expected, inspect service startup/configuration first, then check structured `logtrace` events for the challenge, recheck, and self-healing services. +- If `make test-lep6` fails before tests start, run `make system-test-setup` and confirm `lumerad version` matches the Lumera dependency version. diff --git a/gen/supernode/self_healing.pb.go b/gen/supernode/self_healing.pb.go new file mode 100644 index 00000000..59ae049c --- /dev/null +++ b/gen/supernode/self_healing.pb.go @@ -0,0 +1,265 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.2 +// protoc v4.25.1 +// source: supernode/self_healing.proto + +package supernode + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ServeReconstructedArtefactsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // heal_op_id identifies the heal-op the caller is verifying. Server uses it + // to look up the staging dir and authorize the caller against + // op.VerifierSupernodeAccounts. + HealOpId uint64 `protobuf:"varint,1,opt,name=heal_op_id,json=healOpId,proto3" json:"heal_op_id,omitempty"` + // verifier_account is the caller's chain-side supernode account address. + // Server cross-checks against authenticated grpc identity AND against + // op.VerifierSupernodeAccounts. + VerifierAccount string `protobuf:"bytes,2,opt,name=verifier_account,json=verifierAccount,proto3" json:"verifier_account,omitempty"` +} + +func (x *ServeReconstructedArtefactsRequest) Reset() { + *x = ServeReconstructedArtefactsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_supernode_self_healing_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ServeReconstructedArtefactsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServeReconstructedArtefactsRequest) ProtoMessage() {} + +func (x *ServeReconstructedArtefactsRequest) ProtoReflect() protoreflect.Message { + mi := &file_supernode_self_healing_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServeReconstructedArtefactsRequest.ProtoReflect.Descriptor instead. +func (*ServeReconstructedArtefactsRequest) Descriptor() ([]byte, []int) { + return file_supernode_self_healing_proto_rawDescGZIP(), []int{0} +} + +func (x *ServeReconstructedArtefactsRequest) GetHealOpId() uint64 { + if x != nil { + return x.HealOpId + } + return 0 +} + +func (x *ServeReconstructedArtefactsRequest) GetVerifierAccount() string { + if x != nil { + return x.VerifierAccount + } + return "" +} + +type ServeReconstructedArtefactsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // chunk is a contiguous slice of the reconstructed file bytes. Chunks are + // ordered; concatenating chunks across the stream yields the original file + // whose BLAKE3 must equal op.ResultHash (= action.DataHash recipe). + Chunk []byte `protobuf:"bytes,1,opt,name=chunk,proto3" json:"chunk,omitempty"` + // total_size is the full file size; populated on the first message and + // optionally repeated. Allows clients to pre-allocate buffers. + TotalSize uint64 `protobuf:"varint,2,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"` + // is_last indicates this message carries the final chunk. + IsLast bool `protobuf:"varint,3,opt,name=is_last,json=isLast,proto3" json:"is_last,omitempty"` +} + +func (x *ServeReconstructedArtefactsResponse) Reset() { + *x = ServeReconstructedArtefactsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_supernode_self_healing_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ServeReconstructedArtefactsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServeReconstructedArtefactsResponse) ProtoMessage() {} + +func (x *ServeReconstructedArtefactsResponse) ProtoReflect() protoreflect.Message { + mi := &file_supernode_self_healing_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServeReconstructedArtefactsResponse.ProtoReflect.Descriptor instead. +func (*ServeReconstructedArtefactsResponse) Descriptor() ([]byte, []int) { + return file_supernode_self_healing_proto_rawDescGZIP(), []int{1} +} + +func (x *ServeReconstructedArtefactsResponse) GetChunk() []byte { + if x != nil { + return x.Chunk + } + return nil +} + +func (x *ServeReconstructedArtefactsResponse) GetTotalSize() uint64 { + if x != nil { + return x.TotalSize + } + return 0 +} + +func (x *ServeReconstructedArtefactsResponse) GetIsLast() bool { + if x != nil { + return x.IsLast + } + return false +} + +var File_supernode_self_healing_proto protoreflect.FileDescriptor + +var file_supernode_self_healing_proto_rawDesc = []byte{ + 0x0a, 0x1c, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x73, 0x65, 0x6c, 0x66, + 0x5f, 0x68, 0x65, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, + 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x22, 0x6d, 0x0a, 0x22, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x65, 0x64, 0x41, + 0x72, 0x74, 0x65, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x1c, 0x0a, 0x0a, 0x68, 0x65, 0x61, 0x6c, 0x5f, 0x6f, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x08, 0x68, 0x65, 0x61, 0x6c, 0x4f, 0x70, 0x49, 0x64, 0x12, 0x29, 0x0a, + 0x10, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x72, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, + 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x73, 0x0a, 0x23, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x65, 0x64, 0x41, 0x72, + 0x74, 0x65, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x14, 0x0a, 0x05, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, + 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x73, + 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, + 0x53, 0x69, 0x7a, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x6c, 0x61, 0x73, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4c, 0x61, 0x73, 0x74, 0x32, 0x97, 0x01, + 0x0a, 0x12, 0x53, 0x65, 0x6c, 0x66, 0x48, 0x65, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x12, 0x80, 0x01, 0x0a, 0x1b, 0x53, 0x65, 0x72, 0x76, 0x65, 0x52, 0x65, + 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x65, 0x64, 0x41, 0x72, 0x74, 0x65, 0x66, + 0x61, 0x63, 0x74, 0x73, 0x12, 0x2d, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, + 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x75, 0x63, + 0x74, 0x65, 0x64, 0x41, 0x72, 0x74, 0x65, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, + 0x65, 0x64, 0x41, 0x72, 0x74, 0x65, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4c, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x50, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x2f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x76, + 0x32, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_supernode_self_healing_proto_rawDescOnce sync.Once + file_supernode_self_healing_proto_rawDescData = file_supernode_self_healing_proto_rawDesc +) + +func file_supernode_self_healing_proto_rawDescGZIP() []byte { + file_supernode_self_healing_proto_rawDescOnce.Do(func() { + file_supernode_self_healing_proto_rawDescData = protoimpl.X.CompressGZIP(file_supernode_self_healing_proto_rawDescData) + }) + return file_supernode_self_healing_proto_rawDescData +} + +var file_supernode_self_healing_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_supernode_self_healing_proto_goTypes = []any{ + (*ServeReconstructedArtefactsRequest)(nil), // 0: supernode.ServeReconstructedArtefactsRequest + (*ServeReconstructedArtefactsResponse)(nil), // 1: supernode.ServeReconstructedArtefactsResponse +} +var file_supernode_self_healing_proto_depIdxs = []int32{ + 0, // 0: supernode.SelfHealingService.ServeReconstructedArtefacts:input_type -> supernode.ServeReconstructedArtefactsRequest + 1, // 1: supernode.SelfHealingService.ServeReconstructedArtefacts:output_type -> supernode.ServeReconstructedArtefactsResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_supernode_self_healing_proto_init() } +func file_supernode_self_healing_proto_init() { + if File_supernode_self_healing_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_supernode_self_healing_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*ServeReconstructedArtefactsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_self_healing_proto_msgTypes[1].Exporter = func(v any, i int) any { + switch v := v.(*ServeReconstructedArtefactsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_supernode_self_healing_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_supernode_self_healing_proto_goTypes, + DependencyIndexes: file_supernode_self_healing_proto_depIdxs, + MessageInfos: file_supernode_self_healing_proto_msgTypes, + }.Build() + File_supernode_self_healing_proto = out.File + file_supernode_self_healing_proto_rawDesc = nil + file_supernode_self_healing_proto_goTypes = nil + file_supernode_self_healing_proto_depIdxs = nil +} diff --git a/gen/supernode/self_healing.swagger.json b/gen/supernode/self_healing.swagger.json new file mode 100644 index 00000000..41f787b6 --- /dev/null +++ b/gen/supernode/self_healing.swagger.json @@ -0,0 +1,68 @@ +{ + "swagger": "2.0", + "info": { + "title": "supernode/self_healing.proto", + "version": "version not set" + }, + "tags": [ + { + "name": "SelfHealingService" + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": {}, + "definitions": { + "protobufAny": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "rpcStatus": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protobufAny" + } + } + } + }, + "supernodeServeReconstructedArtefactsResponse": { + "type": "object", + "properties": { + "chunk": { + "type": "string", + "format": "byte", + "description": "chunk is a contiguous slice of the reconstructed file bytes. Chunks are\nordered; concatenating chunks across the stream yields the original file\nwhose BLAKE3 must equal op.ResultHash (= action.DataHash recipe)." + }, + "totalSize": { + "type": "string", + "format": "uint64", + "description": "total_size is the full file size; populated on the first message and\noptionally repeated. Allows clients to pre-allocate buffers." + }, + "isLast": { + "type": "boolean", + "description": "is_last indicates this message carries the final chunk." + } + } + } + } +} diff --git a/gen/supernode/self_healing_grpc.pb.go b/gen/supernode/self_healing_grpc.pb.go new file mode 100644 index 00000000..759116bb --- /dev/null +++ b/gen/supernode/self_healing_grpc.pb.go @@ -0,0 +1,166 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v4.25.1 +// source: supernode/self_healing.proto + +package supernode + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + SelfHealingService_ServeReconstructedArtefacts_FullMethodName = "/supernode.SelfHealingService/ServeReconstructedArtefacts" +) + +// SelfHealingServiceClient is the client API for SelfHealingService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// SelfHealingService — LEP-6 §19 healer-served path. +// +// LEP-6 prescribes a strict three-phase heal flow: +// +// Phase 1 (RECONSTRUCT) — assigned healer reconstructs the file locally and +// submits MsgClaimHealComplete{HealManifestHash}. Artefacts are STAGED +// locally; they are NOT yet published to KAD. +// Phase 2 (VERIFY) — assigned verifiers MUST fetch the reconstructed bytes +// directly from the assigned healer (this RPC), not from KAD, because the +// healer-served path is the only authority before chain VERIFIED. +// Phase 3 (PUBLISH) — only after chain reaches VERIFIED quorum the healer's +// finalizer publishes staged artefacts to KAD via the same store path +// register/upload uses. +// +// This service exposes only the verifier-side fetch — chain coordinates role +// assignment and quorum, so the legacy peer Request/Verify/Commit RPCs are +// gone in the LEP-6 model. +type SelfHealingServiceClient interface { + // ServeReconstructedArtefacts streams the reconstructed file bytes to an + // authorized verifier. The handler MUST verify caller ∈ + // op.VerifierSupernodeAccounts before serving any bytes. + ServeReconstructedArtefacts(ctx context.Context, in *ServeReconstructedArtefactsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ServeReconstructedArtefactsResponse], error) +} + +type selfHealingServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSelfHealingServiceClient(cc grpc.ClientConnInterface) SelfHealingServiceClient { + return &selfHealingServiceClient{cc} +} + +func (c *selfHealingServiceClient) ServeReconstructedArtefacts(ctx context.Context, in *ServeReconstructedArtefactsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ServeReconstructedArtefactsResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &SelfHealingService_ServiceDesc.Streams[0], SelfHealingService_ServeReconstructedArtefacts_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ServeReconstructedArtefactsRequest, ServeReconstructedArtefactsResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type SelfHealingService_ServeReconstructedArtefactsClient = grpc.ServerStreamingClient[ServeReconstructedArtefactsResponse] + +// SelfHealingServiceServer is the server API for SelfHealingService service. +// All implementations must embed UnimplementedSelfHealingServiceServer +// for forward compatibility. +// +// SelfHealingService — LEP-6 §19 healer-served path. +// +// LEP-6 prescribes a strict three-phase heal flow: +// +// Phase 1 (RECONSTRUCT) — assigned healer reconstructs the file locally and +// submits MsgClaimHealComplete{HealManifestHash}. Artefacts are STAGED +// locally; they are NOT yet published to KAD. +// Phase 2 (VERIFY) — assigned verifiers MUST fetch the reconstructed bytes +// directly from the assigned healer (this RPC), not from KAD, because the +// healer-served path is the only authority before chain VERIFIED. +// Phase 3 (PUBLISH) — only after chain reaches VERIFIED quorum the healer's +// finalizer publishes staged artefacts to KAD via the same store path +// register/upload uses. +// +// This service exposes only the verifier-side fetch — chain coordinates role +// assignment and quorum, so the legacy peer Request/Verify/Commit RPCs are +// gone in the LEP-6 model. +type SelfHealingServiceServer interface { + // ServeReconstructedArtefacts streams the reconstructed file bytes to an + // authorized verifier. The handler MUST verify caller ∈ + // op.VerifierSupernodeAccounts before serving any bytes. + ServeReconstructedArtefacts(*ServeReconstructedArtefactsRequest, grpc.ServerStreamingServer[ServeReconstructedArtefactsResponse]) error + mustEmbedUnimplementedSelfHealingServiceServer() +} + +// UnimplementedSelfHealingServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedSelfHealingServiceServer struct{} + +func (UnimplementedSelfHealingServiceServer) ServeReconstructedArtefacts(*ServeReconstructedArtefactsRequest, grpc.ServerStreamingServer[ServeReconstructedArtefactsResponse]) error { + return status.Errorf(codes.Unimplemented, "method ServeReconstructedArtefacts not implemented") +} +func (UnimplementedSelfHealingServiceServer) mustEmbedUnimplementedSelfHealingServiceServer() {} +func (UnimplementedSelfHealingServiceServer) testEmbeddedByValue() {} + +// UnsafeSelfHealingServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SelfHealingServiceServer will +// result in compilation errors. +type UnsafeSelfHealingServiceServer interface { + mustEmbedUnimplementedSelfHealingServiceServer() +} + +func RegisterSelfHealingServiceServer(s grpc.ServiceRegistrar, srv SelfHealingServiceServer) { + // If the following call pancis, it indicates UnimplementedSelfHealingServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&SelfHealingService_ServiceDesc, srv) +} + +func _SelfHealingService_ServeReconstructedArtefacts_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ServeReconstructedArtefactsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(SelfHealingServiceServer).ServeReconstructedArtefacts(m, &grpc.GenericServerStream[ServeReconstructedArtefactsRequest, ServeReconstructedArtefactsResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type SelfHealingService_ServeReconstructedArtefactsServer = grpc.ServerStreamingServer[ServeReconstructedArtefactsResponse] + +// SelfHealingService_ServiceDesc is the grpc.ServiceDesc for SelfHealingService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SelfHealingService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "supernode.SelfHealingService", + HandlerType: (*SelfHealingServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "ServeReconstructedArtefacts", + Handler: _SelfHealingService_ServeReconstructedArtefacts_Handler, + ServerStreams: true, + }, + }, + Metadata: "supernode/self_healing.proto", +} diff --git a/gen/supernode/service.pb.go b/gen/supernode/service.pb.go index f74c97f0..90990d10 100644 --- a/gen/supernode/service.pb.go +++ b/gen/supernode/service.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 -// protoc v3.21.12 +// protoc-gen-go v1.34.2 +// protoc v4.25.1 // source: supernode/service.proto package supernode @@ -12,7 +12,6 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" - unsafe "unsafe" ) const ( @@ -23,16 +22,18 @@ const ( ) type ListServicesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields } func (x *ListServicesRequest) Reset() { *x = ListServicesRequest{} - mi := &file_supernode_service_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *ListServicesRequest) String() string { @@ -43,7 +44,7 @@ func (*ListServicesRequest) ProtoMessage() {} func (x *ListServicesRequest) ProtoReflect() protoreflect.Message { mi := &file_supernode_service_proto_msgTypes[0] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -59,18 +60,21 @@ func (*ListServicesRequest) Descriptor() ([]byte, []int) { } type ListServicesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Services []*ServiceInfo `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty"` - Count int32 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Services []*ServiceInfo `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty"` + Count int32 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` } func (x *ListServicesResponse) Reset() { *x = ListServicesResponse{} - mi := &file_supernode_service_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *ListServicesResponse) String() string { @@ -81,7 +85,7 @@ func (*ListServicesResponse) ProtoMessage() {} func (x *ListServicesResponse) ProtoReflect() protoreflect.Message { mi := &file_supernode_service_proto_msgTypes[1] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -111,18 +115,21 @@ func (x *ListServicesResponse) GetCount() int32 { } type ServiceInfo struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Methods []string `protobuf:"bytes,2,rep,name=methods,proto3" json:"methods,omitempty"` - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Methods []string `protobuf:"bytes,2,rep,name=methods,proto3" json:"methods,omitempty"` } func (x *ServiceInfo) Reset() { *x = ServiceInfo{} - mi := &file_supernode_service_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *ServiceInfo) String() string { @@ -133,7 +140,7 @@ func (*ServiceInfo) ProtoMessage() {} func (x *ServiceInfo) ProtoReflect() protoreflect.Message { mi := &file_supernode_service_proto_msgTypes[2] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -164,17 +171,20 @@ func (x *ServiceInfo) GetMethods() []string { // Raw pprof request/response messages type RawPprofRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Debug int32 `protobuf:"varint,1,opt,name=debug,proto3" json:"debug,omitempty"` // Debug level (0 for binary, >0 for text) - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Debug int32 `protobuf:"varint,1,opt,name=debug,proto3" json:"debug,omitempty"` // Debug level (0 for binary, >0 for text) } func (x *RawPprofRequest) Reset() { *x = RawPprofRequest{} - mi := &file_supernode_service_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_service_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *RawPprofRequest) String() string { @@ -185,7 +195,7 @@ func (*RawPprofRequest) ProtoMessage() {} func (x *RawPprofRequest) ProtoReflect() protoreflect.Message { mi := &file_supernode_service_proto_msgTypes[3] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -208,17 +218,20 @@ func (x *RawPprofRequest) GetDebug() int32 { } type RawPprofCpuRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Seconds int32 `protobuf:"varint,1,opt,name=seconds,proto3" json:"seconds,omitempty"` // CPU profile duration in seconds (default 30) - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Seconds int32 `protobuf:"varint,1,opt,name=seconds,proto3" json:"seconds,omitempty"` // CPU profile duration in seconds (default 30) } func (x *RawPprofCpuRequest) Reset() { *x = RawPprofCpuRequest{} - mi := &file_supernode_service_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *RawPprofCpuRequest) String() string { @@ -229,7 +242,7 @@ func (*RawPprofCpuRequest) ProtoMessage() {} func (x *RawPprofCpuRequest) ProtoReflect() protoreflect.Message { mi := &file_supernode_service_proto_msgTypes[4] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -252,17 +265,20 @@ func (x *RawPprofCpuRequest) GetSeconds() int32 { } type RawPprofResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` // Raw pprof data exactly as returned by runtime/pprof - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` // Raw pprof data exactly as returned by runtime/pprof } func (x *RawPprofResponse) Reset() { *x = RawPprofResponse{} - mi := &file_supernode_service_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *RawPprofResponse) String() string { @@ -273,7 +289,7 @@ func (*RawPprofResponse) ProtoMessage() {} func (x *RawPprofResponse) ProtoReflect() protoreflect.Message { mi := &file_supernode_service_proto_msgTypes[5] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -297,45 +313,141 @@ func (x *RawPprofResponse) GetData() []byte { var File_supernode_service_proto protoreflect.FileDescriptor -const file_supernode_service_proto_rawDesc = "" + - "\n" + - "\x17supernode/service.proto\x12\tsupernode\x1a\x16supernode/status.proto\x1a\x1cgoogle/api/annotations.proto\"\x15\n" + - "\x13ListServicesRequest\"`\n" + - "\x14ListServicesResponse\x122\n" + - "\bservices\x18\x01 \x03(\v2\x16.supernode.ServiceInfoR\bservices\x12\x14\n" + - "\x05count\x18\x02 \x01(\x05R\x05count\";\n" + - "\vServiceInfo\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + - "\amethods\x18\x02 \x03(\tR\amethods\"'\n" + - "\x0fRawPprofRequest\x12\x14\n" + - "\x05debug\x18\x01 \x01(\x05R\x05debug\".\n" + - "\x12RawPprofCpuRequest\x12\x18\n" + - "\aseconds\x18\x01 \x01(\x05R\aseconds\"&\n" + - "\x10RawPprofResponse\x12\x12\n" + - "\x04data\x18\x01 \x01(\fR\x04data2\xec\v\n" + - "\x10SupernodeService\x12X\n" + - "\tGetStatus\x12\x18.supernode.StatusRequest\x1a\x19.supernode.StatusResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/status\x12i\n" + - "\fListServices\x12\x1e.supernode.ListServicesRequest\x1a\x1f.supernode.ListServicesResponse\"\x18\x82\xd3\xe4\x93\x02\x12\x12\x10/api/v1/services\x12g\n" + - "\vGetRawPprof\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\"\x1f\x82\xd3\xe4\x93\x02\x19\x12\x17/api/v1/debug/raw/pprof\x12p\n" + - "\x0fGetRawPprofHeap\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\"$\x82\xd3\xe4\x93\x02\x1e\x12\x1c/api/v1/debug/raw/pprof/heap\x12z\n" + - "\x14GetRawPprofGoroutine\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\")\x82\xd3\xe4\x93\x02#\x12!/api/v1/debug/raw/pprof/goroutine\x12t\n" + - "\x11GetRawPprofAllocs\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\"&\x82\xd3\xe4\x93\x02 \x12\x1e/api/v1/debug/raw/pprof/allocs\x12r\n" + - "\x10GetRawPprofBlock\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\"%\x82\xd3\xe4\x93\x02\x1f\x12\x1d/api/v1/debug/raw/pprof/block\x12r\n" + - "\x10GetRawPprofMutex\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\"%\x82\xd3\xe4\x93\x02\x1f\x12\x1d/api/v1/debug/raw/pprof/mutex\x12\x80\x01\n" + - "\x17GetRawPprofThreadcreate\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\",\x82\xd3\xe4\x93\x02&\x12$/api/v1/debug/raw/pprof/threadcreate\x12y\n" + - "\x12GetRawPprofProfile\x12\x1d.supernode.RawPprofCpuRequest\x1a\x1b.supernode.RawPprofResponse\"'\x82\xd3\xe4\x93\x02!\x12\x1f/api/v1/debug/raw/pprof/profile\x12v\n" + - "\x12GetRawPprofCmdline\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\"'\x82\xd3\xe4\x93\x02!\x12\x1f/api/v1/debug/raw/pprof/cmdline\x12t\n" + - "\x11GetRawPprofSymbol\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\"&\x82\xd3\xe4\x93\x02 \x12\x1e/api/v1/debug/raw/pprof/symbol\x12r\n" + - "\x10GetRawPprofTrace\x12\x1a.supernode.RawPprofRequest\x1a\x1b.supernode.RawPprofResponse\"%\x82\xd3\xe4\x93\x02\x1f\x12\x1d/api/v1/debug/raw/pprof/traceB6Z4github.com/LumeraProtocol/supernode/v2/gen/supernodeb\x06proto3" +var file_supernode_service_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x73, 0x75, 0x70, 0x65, 0x72, + 0x6e, 0x6f, 0x64, 0x65, 0x1a, 0x16, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1c, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x15, 0x0a, 0x13, 0x4c, 0x69, + 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x22, 0x60, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x08, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x73, 0x75, + 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, + 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, 0x14, 0x0a, + 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x22, 0x3b, 0x0a, 0x0b, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x6e, + 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x73, + 0x22, 0x27, 0x0a, 0x0f, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x62, 0x75, 0x67, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x05, 0x52, 0x05, 0x64, 0x65, 0x62, 0x75, 0x67, 0x22, 0x2e, 0x0a, 0x12, 0x52, 0x61, 0x77, + 0x50, 0x70, 0x72, 0x6f, 0x66, 0x43, 0x70, 0x75, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x18, 0x0a, 0x07, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x07, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x22, 0x26, 0x0a, 0x10, 0x52, 0x61, 0x77, + 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, + 0x61, 0x32, 0xec, 0x0b, 0x0a, 0x10, 0x53, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x58, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x12, 0x18, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, + 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x10, + 0x12, 0x0e, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x12, 0x69, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, + 0x12, 0x1e, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1f, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x18, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x12, 0x12, 0x10, 0x2f, 0x61, 0x70, 0x69, 0x2f, + 0x76, 0x31, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, 0x67, 0x0a, 0x0b, 0x47, + 0x65, 0x74, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x12, 0x1a, 0x2e, 0x73, 0x75, 0x70, + 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, + 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x1f, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x12, 0x17, 0x2f, 0x61, 0x70, + 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, 0x2f, 0x72, 0x61, 0x77, 0x2f, 0x70, + 0x70, 0x72, 0x6f, 0x66, 0x12, 0x70, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x52, 0x61, 0x77, 0x50, 0x70, + 0x72, 0x6f, 0x66, 0x48, 0x65, 0x61, 0x70, 0x12, 0x1a, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, + 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, + 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x24, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1e, 0x12, 0x1c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, + 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, 0x2f, 0x72, 0x61, 0x77, 0x2f, 0x70, 0x70, 0x72, 0x6f, + 0x66, 0x2f, 0x68, 0x65, 0x61, 0x70, 0x12, 0x7a, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x52, 0x61, 0x77, + 0x50, 0x70, 0x72, 0x6f, 0x66, 0x47, 0x6f, 0x72, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x65, 0x12, 0x1a, + 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, + 0x72, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, 0x75, 0x70, + 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x29, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x23, 0x12, + 0x21, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, 0x2f, 0x72, + 0x61, 0x77, 0x2f, 0x70, 0x70, 0x72, 0x6f, 0x66, 0x2f, 0x67, 0x6f, 0x72, 0x6f, 0x75, 0x74, 0x69, + 0x6e, 0x65, 0x12, 0x74, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, + 0x66, 0x41, 0x6c, 0x6c, 0x6f, 0x63, 0x73, 0x12, 0x1a, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, + 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, + 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x26, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x20, 0x12, 0x1e, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, + 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, 0x2f, 0x72, 0x61, 0x77, 0x2f, 0x70, 0x70, 0x72, 0x6f, + 0x66, 0x2f, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x73, 0x12, 0x72, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x52, + 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x12, 0x1a, 0x2e, 0x73, + 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, + 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, + 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1f, 0x12, 0x1d, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, 0x2f, 0x72, 0x61, 0x77, + 0x2f, 0x70, 0x70, 0x72, 0x6f, 0x66, 0x2f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x12, 0x72, 0x0a, 0x10, + 0x47, 0x65, 0x74, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x4d, 0x75, 0x74, 0x65, 0x78, + 0x12, 0x1a, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, + 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, + 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, + 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x1f, 0x12, 0x1d, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, + 0x2f, 0x72, 0x61, 0x77, 0x2f, 0x70, 0x70, 0x72, 0x6f, 0x66, 0x2f, 0x6d, 0x75, 0x74, 0x65, 0x78, + 0x12, 0x80, 0x01, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, + 0x54, 0x68, 0x72, 0x65, 0x61, 0x64, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x1a, 0x2e, 0x73, + 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, + 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, + 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2c, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x26, 0x12, 0x24, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, 0x2f, 0x72, 0x61, 0x77, + 0x2f, 0x70, 0x70, 0x72, 0x6f, 0x66, 0x2f, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x12, 0x79, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, + 0x6f, 0x66, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x1d, 0x2e, 0x73, 0x75, 0x70, 0x65, + 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x43, 0x70, + 0x75, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, + 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x27, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x21, 0x12, 0x1f, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, 0x2f, 0x72, 0x61, 0x77, + 0x2f, 0x70, 0x70, 0x72, 0x6f, 0x66, 0x2f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x76, + 0x0a, 0x12, 0x47, 0x65, 0x74, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x43, 0x6d, 0x64, + 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x1a, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, + 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, + 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x27, 0x82, + 0xd3, 0xe4, 0x93, 0x02, 0x21, 0x12, 0x1f, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x64, + 0x65, 0x62, 0x75, 0x67, 0x2f, 0x72, 0x61, 0x77, 0x2f, 0x70, 0x70, 0x72, 0x6f, 0x66, 0x2f, 0x63, + 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x74, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x52, 0x61, 0x77, + 0x50, 0x70, 0x72, 0x6f, 0x66, 0x53, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x1a, 0x2e, 0x73, 0x75, + 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, + 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x20, 0x12, 0x1e, 0x2f, 0x61, + 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, 0x2f, 0x72, 0x61, 0x77, 0x2f, + 0x70, 0x70, 0x72, 0x6f, 0x66, 0x2f, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x72, 0x0a, 0x10, + 0x47, 0x65, 0x74, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, 0x66, 0x54, 0x72, 0x61, 0x63, 0x65, + 0x12, 0x1a, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, + 0x50, 0x70, 0x72, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x73, + 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x52, 0x61, 0x77, 0x50, 0x70, 0x72, 0x6f, + 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x1f, 0x12, 0x1d, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x62, 0x75, 0x67, + 0x2f, 0x72, 0x61, 0x77, 0x2f, 0x70, 0x70, 0x72, 0x6f, 0x66, 0x2f, 0x74, 0x72, 0x61, 0x63, 0x65, + 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4c, + 0x75, 0x6d, 0x65, 0x72, 0x61, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x2f, 0x73, 0x75, + 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x76, 0x32, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x73, + 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} var ( file_supernode_service_proto_rawDescOnce sync.Once - file_supernode_service_proto_rawDescData []byte + file_supernode_service_proto_rawDescData = file_supernode_service_proto_rawDesc ) func file_supernode_service_proto_rawDescGZIP() []byte { file_supernode_service_proto_rawDescOnce.Do(func() { - file_supernode_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_supernode_service_proto_rawDesc), len(file_supernode_service_proto_rawDesc))) + file_supernode_service_proto_rawDescData = protoimpl.X.CompressGZIP(file_supernode_service_proto_rawDescData) }) return file_supernode_service_proto_rawDescData } @@ -392,11 +504,85 @@ func file_supernode_service_proto_init() { return } file_supernode_status_proto_init() + if !protoimpl.UnsafeEnabled { + file_supernode_service_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*ListServicesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_service_proto_msgTypes[1].Exporter = func(v any, i int) any { + switch v := v.(*ListServicesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_service_proto_msgTypes[2].Exporter = func(v any, i int) any { + switch v := v.(*ServiceInfo); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_service_proto_msgTypes[3].Exporter = func(v any, i int) any { + switch v := v.(*RawPprofRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_service_proto_msgTypes[4].Exporter = func(v any, i int) any { + switch v := v.(*RawPprofCpuRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_service_proto_msgTypes[5].Exporter = func(v any, i int) any { + switch v := v.(*RawPprofResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_supernode_service_proto_rawDesc), len(file_supernode_service_proto_rawDesc)), + RawDescriptor: file_supernode_service_proto_rawDesc, NumEnums: 0, NumMessages: 6, NumExtensions: 0, @@ -407,6 +593,7 @@ func file_supernode_service_proto_init() { MessageInfos: file_supernode_service_proto_msgTypes, }.Build() File_supernode_service_proto = out.File + file_supernode_service_proto_rawDesc = nil file_supernode_service_proto_goTypes = nil file_supernode_service_proto_depIdxs = nil } diff --git a/gen/supernode/service.pb.gw.go b/gen/supernode/service.pb.gw.go index 89e6ca78..93983b0f 100644 --- a/gen/supernode/service.pb.gw.go +++ b/gen/supernode/service.pb.gw.go @@ -10,7 +10,6 @@ package supernode import ( "context" - "errors" "io" "net/http" @@ -25,470 +24,478 @@ import ( ) // Suppress "imported and not used" errors +var _ codes.Code +var _ io.Reader +var _ status.Status +var _ = runtime.String +var _ = utilities.NewDoubleArray +var _ = metadata.Join + var ( - _ codes.Code - _ io.Reader - _ status.Status - _ = errors.New - _ = runtime.String - _ = utilities.NewDoubleArray - _ = metadata.Join + filter_SupernodeService_GetStatus_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} ) -var filter_SupernodeService_GetStatus_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - func request_SupernodeService_GetStatus_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq StatusRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq StatusRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetStatus_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetStatus(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetStatus_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq StatusRequest - metadata runtime.ServerMetadata - ) + var protoReq StatusRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetStatus_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetStatus(ctx, &protoReq) return msg, metadata, err + } func request_SupernodeService_ListServices_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListServicesRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq ListServicesRequest + var metadata runtime.ServerMetadata + msg, err := client.ListServices(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_ListServices_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListServicesRequest - metadata runtime.ServerMetadata - ) + var protoReq ListServicesRequest + var metadata runtime.ServerMetadata + msg, err := server.ListServices(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprof_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprof_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprof_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprof_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprof(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprof_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprof_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprof(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofHeap_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofHeap_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofHeap_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofHeap_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofHeap(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofHeap_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofHeap_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofHeap(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofGoroutine_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofGoroutine_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofGoroutine_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofGoroutine_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofGoroutine(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofGoroutine_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofGoroutine_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofGoroutine(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofAllocs_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofAllocs_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofAllocs_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofAllocs_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofAllocs(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofAllocs_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofAllocs_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofAllocs(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofBlock_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofBlock_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofBlock_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofBlock_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofBlock(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofBlock_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofBlock_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofBlock(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofMutex_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofMutex_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofMutex_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofMutex_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofMutex(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofMutex_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofMutex_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofMutex(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofThreadcreate_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofThreadcreate_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofThreadcreate_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofThreadcreate_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofThreadcreate(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofThreadcreate_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofThreadcreate_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofThreadcreate(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofProfile_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofProfile_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofProfile_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofCpuRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofCpuRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofProfile_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofProfile_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofCpuRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofCpuRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofProfile_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofProfile(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofCmdline_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofCmdline_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofCmdline_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofCmdline_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofCmdline(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofCmdline_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofCmdline_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofCmdline(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofSymbol_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofSymbol_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofSymbol_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofSymbol_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofSymbol(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofSymbol_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofSymbol_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofSymbol(ctx, &protoReq) return msg, metadata, err + } -var filter_SupernodeService_GetRawPprofTrace_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var ( + filter_SupernodeService_GetRawPprofTrace_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) func request_SupernodeService_GetRawPprofTrace_0(ctx context.Context, marshaler runtime.Marshaler, client SupernodeServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofTrace_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := client.GetRawPprofTrace(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err + } func local_request_SupernodeService_GetRawPprofTrace_0(ctx context.Context, marshaler runtime.Marshaler, server SupernodeServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RawPprofRequest - metadata runtime.ServerMetadata - ) + var protoReq RawPprofRequest + var metadata runtime.ServerMetadata + if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_SupernodeService_GetRawPprofTrace_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } + msg, err := server.GetRawPprofTrace(ctx, &protoReq) return msg, metadata, err + } // RegisterSupernodeServiceHandlerServer registers the http handlers for service SupernodeService to "mux". // UnaryRPC :call SupernodeServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. // Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterSupernodeServiceHandlerFromEndpoint instead. -// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server SupernodeServiceServer) error { - mux.Handle(http.MethodGet, pattern_SupernodeService_GetStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetStatus", runtime.WithHTTPPathPattern("/api/v1/status")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetStatus", runtime.WithHTTPPathPattern("/api/v1/status")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -500,15 +507,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetStatus_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_ListServices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_ListServices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/ListServices", runtime.WithHTTPPathPattern("/api/v1/services")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/ListServices", runtime.WithHTTPPathPattern("/api/v1/services")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -520,15 +532,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_ListServices_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprof_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprof_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprof", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprof", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -540,15 +557,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprof_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofHeap_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofHeap_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofHeap", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/heap")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofHeap", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/heap")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -560,15 +582,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofHeap_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofGoroutine_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofGoroutine_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofGoroutine", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/goroutine")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofGoroutine", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/goroutine")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -580,15 +607,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofGoroutine_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofAllocs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofAllocs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofAllocs", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/allocs")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofAllocs", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/allocs")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -600,15 +632,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofAllocs_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofBlock_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofBlock_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofBlock", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/block")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofBlock", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/block")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -620,15 +657,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofBlock_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofMutex_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofMutex_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofMutex", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/mutex")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofMutex", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/mutex")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -640,15 +682,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofMutex_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofThreadcreate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofThreadcreate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofThreadcreate", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/threadcreate")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofThreadcreate", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/threadcreate")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -660,15 +707,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofThreadcreate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofProfile", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/profile")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofProfile", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/profile")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -680,15 +732,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofCmdline_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofCmdline_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofCmdline", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/cmdline")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofCmdline", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/cmdline")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -700,15 +757,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofCmdline_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofSymbol_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofSymbol_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofSymbol", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/symbol")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofSymbol", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/symbol")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -720,15 +782,20 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofSymbol_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofTrace_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofTrace_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofTrace", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/trace")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofTrace", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/trace")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -740,7 +807,9 @@ func RegisterSupernodeServiceHandlerServer(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofTrace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil @@ -767,6 +836,7 @@ func RegisterSupernodeServiceHandlerFromEndpoint(ctx context.Context, mux *runti } }() }() + return RegisterSupernodeServiceHandler(ctx, mux, conn) } @@ -780,13 +850,16 @@ func RegisterSupernodeServiceHandler(ctx context.Context, mux *runtime.ServeMux, // to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "SupernodeServiceClient". // Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "SupernodeServiceClient" // doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in -// "SupernodeServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +// "SupernodeServiceClient" to call the correct interceptors. func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client SupernodeServiceClient) error { - mux.Handle(http.MethodGet, pattern_SupernodeService_GetStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetStatus", runtime.WithHTTPPathPattern("/api/v1/status")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetStatus", runtime.WithHTTPPathPattern("/api/v1/status")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -797,13 +870,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetStatus_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_ListServices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_ListServices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/ListServices", runtime.WithHTTPPathPattern("/api/v1/services")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/ListServices", runtime.WithHTTPPathPattern("/api/v1/services")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -814,13 +892,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_ListServices_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprof_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprof_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprof", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprof", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -831,13 +914,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprof_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofHeap_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofHeap_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofHeap", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/heap")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofHeap", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/heap")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -848,13 +936,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofHeap_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofGoroutine_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofGoroutine_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofGoroutine", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/goroutine")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofGoroutine", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/goroutine")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -865,13 +958,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofGoroutine_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofAllocs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofAllocs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofAllocs", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/allocs")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofAllocs", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/allocs")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -882,13 +980,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofAllocs_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofBlock_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofBlock_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofBlock", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/block")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofBlock", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/block")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -899,13 +1002,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofBlock_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofMutex_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofMutex_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofMutex", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/mutex")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofMutex", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/mutex")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -916,13 +1024,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofMutex_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofThreadcreate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofThreadcreate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofThreadcreate", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/threadcreate")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofThreadcreate", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/threadcreate")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -933,13 +1046,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofThreadcreate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofProfile", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/profile")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofProfile", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/profile")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -950,13 +1068,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofCmdline_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofCmdline_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofCmdline", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/cmdline")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofCmdline", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/cmdline")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -967,13 +1090,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofCmdline_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofSymbol_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofSymbol_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofSymbol", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/symbol")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofSymbol", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/symbol")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -984,13 +1112,18 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofSymbol_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) - mux.Handle(http.MethodGet, pattern_SupernodeService_GetRawPprofTrace_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + + mux.Handle("GET", pattern_SupernodeService_GetRawPprofTrace_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofTrace", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/trace")) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/supernode.SupernodeService/GetRawPprofTrace", runtime.WithHTTPPathPattern("/api/v1/debug/raw/pprof/trace")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -1001,39 +1134,66 @@ func RegisterSupernodeServiceHandlerClient(ctx context.Context, mux *runtime.Ser runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } + forward_SupernodeService_GetRawPprofTrace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + return nil } var ( - pattern_SupernodeService_GetStatus_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "status"}, "")) - pattern_SupernodeService_ListServices_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "services"}, "")) - pattern_SupernodeService_GetRawPprof_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"api", "v1", "debug", "raw", "pprof"}, "")) - pattern_SupernodeService_GetRawPprofHeap_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "heap"}, "")) - pattern_SupernodeService_GetRawPprofGoroutine_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "goroutine"}, "")) - pattern_SupernodeService_GetRawPprofAllocs_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "allocs"}, "")) - pattern_SupernodeService_GetRawPprofBlock_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "block"}, "")) - pattern_SupernodeService_GetRawPprofMutex_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "mutex"}, "")) + pattern_SupernodeService_GetStatus_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "status"}, "")) + + pattern_SupernodeService_ListServices_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "services"}, "")) + + pattern_SupernodeService_GetRawPprof_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"api", "v1", "debug", "raw", "pprof"}, "")) + + pattern_SupernodeService_GetRawPprofHeap_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "heap"}, "")) + + pattern_SupernodeService_GetRawPprofGoroutine_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "goroutine"}, "")) + + pattern_SupernodeService_GetRawPprofAllocs_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "allocs"}, "")) + + pattern_SupernodeService_GetRawPprofBlock_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "block"}, "")) + + pattern_SupernodeService_GetRawPprofMutex_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "mutex"}, "")) + pattern_SupernodeService_GetRawPprofThreadcreate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "threadcreate"}, "")) - pattern_SupernodeService_GetRawPprofProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "profile"}, "")) - pattern_SupernodeService_GetRawPprofCmdline_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "cmdline"}, "")) - pattern_SupernodeService_GetRawPprofSymbol_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "symbol"}, "")) - pattern_SupernodeService_GetRawPprofTrace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "trace"}, "")) + + pattern_SupernodeService_GetRawPprofProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "profile"}, "")) + + pattern_SupernodeService_GetRawPprofCmdline_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "cmdline"}, "")) + + pattern_SupernodeService_GetRawPprofSymbol_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "symbol"}, "")) + + pattern_SupernodeService_GetRawPprofTrace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "debug", "raw", "pprof", "trace"}, "")) ) var ( - forward_SupernodeService_GetStatus_0 = runtime.ForwardResponseMessage - forward_SupernodeService_ListServices_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprof_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprofHeap_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprofGoroutine_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprofAllocs_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprofBlock_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprofMutex_0 = runtime.ForwardResponseMessage + forward_SupernodeService_GetStatus_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_ListServices_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprof_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprofHeap_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprofGoroutine_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprofAllocs_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprofBlock_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprofMutex_0 = runtime.ForwardResponseMessage + forward_SupernodeService_GetRawPprofThreadcreate_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprofProfile_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprofCmdline_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprofSymbol_0 = runtime.ForwardResponseMessage - forward_SupernodeService_GetRawPprofTrace_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprofProfile_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprofCmdline_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprofSymbol_0 = runtime.ForwardResponseMessage + + forward_SupernodeService_GetRawPprofTrace_0 = runtime.ForwardResponseMessage ) diff --git a/gen/supernode/service.swagger.json b/gen/supernode/service.swagger.json index 523499b8..8be81666 100644 --- a/gen/supernode/service.swagger.json +++ b/gen/supernode/service.swagger.json @@ -668,6 +668,128 @@ } } }, + "StatusResponseLEP6Metrics": { + "type": "object", + "properties": { + "dispatchResultsTotal": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + }, + "description": "Storage challenge / dispatch signals." + }, + "dispatchThrottledTotal": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + } + }, + "dispatchEpochDurationMillisTotal": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + } + }, + "dispatchEpochDurationMillisMax": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + } + }, + "dispatchEpochDurationCount": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + } + }, + "ticketDiscoveryTotal": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + } + }, + "noTicketProviderActive": { + "type": "string", + "format": "int64" + }, + "healClaimsSubmittedTotal": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + }, + "description": "Self-healing signals." + }, + "healClaimsReconciledTotal": { + "type": "string", + "format": "uint64" + }, + "healVerificationsSubmittedTotal": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + } + }, + "healVerificationsAlreadyExistsTotal": { + "type": "string", + "format": "uint64" + }, + "healFinalizePublishesTotal": { + "type": "string", + "format": "uint64" + }, + "healFinalizeCleanupsTotal": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + } + }, + "selfHealingPendingClaims": { + "type": "string", + "format": "int64" + }, + "selfHealingStagingBytes": { + "type": "string", + "format": "int64" + }, + "recheckCandidatesFoundTotal": { + "type": "string", + "format": "uint64", + "description": "Storage recheck signals." + }, + "recheckEvidenceSubmittedTotal": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + } + }, + "recheckEvidenceAlreadySubmittedTotal": { + "type": "string", + "format": "uint64" + }, + "recheckExecutionFailuresTotal": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uint64" + } + }, + "recheckPendingCandidates": { + "type": "string", + "format": "int64" + } + }, + "description": "LEP-6 storage-truth runtime metrics and diagnostics. These are in-memory\ncounters/gauges reset on process restart, matching the existing typed\nstatus-snapshot pattern used for P2P metrics." + }, "StatusResponseNetwork": { "type": "object", "properties": { @@ -876,6 +998,9 @@ }, "p2pMetrics": { "$ref": "#/definitions/StatusResponseP2PMetrics" + }, + "lep6Metrics": { + "$ref": "#/definitions/StatusResponseLEP6Metrics" } }, "title": "The StatusResponse represents system status with clear organization" diff --git a/gen/supernode/service_grpc.pb.go b/gen/supernode/service_grpc.pb.go index 42857bf2..2b905062 100644 --- a/gen/supernode/service_grpc.pb.go +++ b/gen/supernode/service_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v3.21.12 +// - protoc v4.25.1 // source: supernode/service.proto package supernode diff --git a/gen/supernode/status.pb.go b/gen/supernode/status.pb.go index 8b6a75d3..7d79b536 100644 --- a/gen/supernode/status.pb.go +++ b/gen/supernode/status.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 -// protoc v3.21.12 +// protoc-gen-go v1.34.2 +// protoc v4.25.1 // source: supernode/status.proto package supernode @@ -11,7 +11,6 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" - unsafe "unsafe" ) const ( @@ -23,19 +22,22 @@ const ( // StatusRequest controls optional metrics in the status response type StatusRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + // Optional: include detailed P2P metrics in the response // Maps to query param via grpc-gateway: /api/v1/status?include_p2p_metrics=true IncludeP2PMetrics bool `protobuf:"varint,1,opt,name=include_p2p_metrics,json=includeP2pMetrics,proto3" json:"include_p2p_metrics,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache } func (x *StatusRequest) Reset() { *x = StatusRequest{} - mi := &file_supernode_status_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusRequest) String() string { @@ -46,7 +48,7 @@ func (*StatusRequest) ProtoMessage() {} func (x *StatusRequest) ProtoReflect() protoreflect.Message { mi := &file_supernode_status_proto_msgTypes[0] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -70,7 +72,10 @@ func (x *StatusRequest) GetIncludeP2PMetrics() bool { // The StatusResponse represents system status with clear organization type StatusResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // Supernode version UptimeSeconds uint64 `protobuf:"varint,2,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"` // Uptime in seconds Resources *StatusResponse_Resources `protobuf:"bytes,3,opt,name=resources,proto3" json:"resources,omitempty"` @@ -80,15 +85,16 @@ type StatusResponse struct { Rank int32 `protobuf:"varint,7,opt,name=rank,proto3" json:"rank,omitempty"` // Rank in the top supernodes list (0 if not in top list) IpAddress string `protobuf:"bytes,8,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` // Supernode IP address with port (e.g., "192.168.1.1:4445") P2PMetrics *StatusResponse_P2PMetrics `protobuf:"bytes,9,opt,name=p2p_metrics,json=p2pMetrics,proto3" json:"p2p_metrics,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Lep6Metrics *StatusResponse_LEP6Metrics `protobuf:"bytes,10,opt,name=lep6_metrics,json=lep6Metrics,proto3" json:"lep6_metrics,omitempty"` } func (x *StatusResponse) Reset() { *x = StatusResponse{} - mi := &file_supernode_status_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse) String() string { @@ -99,7 +105,7 @@ func (*StatusResponse) ProtoMessage() {} func (x *StatusResponse) ProtoReflect() protoreflect.Message { mi := &file_supernode_status_proto_msgTypes[1] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -177,22 +183,32 @@ func (x *StatusResponse) GetP2PMetrics() *StatusResponse_P2PMetrics { return nil } +func (x *StatusResponse) GetLep6Metrics() *StatusResponse_LEP6Metrics { + if x != nil { + return x.Lep6Metrics + } + return nil +} + // System resource information type StatusResponse_Resources struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + Cpu *StatusResponse_Resources_CPU `protobuf:"bytes,1,opt,name=cpu,proto3" json:"cpu,omitempty"` Memory *StatusResponse_Resources_Memory `protobuf:"bytes,2,opt,name=memory,proto3" json:"memory,omitempty"` StorageVolumes []*StatusResponse_Resources_Storage `protobuf:"bytes,3,rep,name=storage_volumes,json=storageVolumes,proto3" json:"storage_volumes,omitempty"` HardwareSummary string `protobuf:"bytes,4,opt,name=hardware_summary,json=hardwareSummary,proto3" json:"hardware_summary,omitempty"` // Formatted hardware summary (e.g., "8 cores / 32GB RAM") - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache } func (x *StatusResponse_Resources) Reset() { *x = StatusResponse_Resources{} - mi := &file_supernode_status_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_Resources) String() string { @@ -203,7 +219,7 @@ func (*StatusResponse_Resources) ProtoMessage() {} func (x *StatusResponse_Resources) ProtoReflect() protoreflect.Message { mi := &file_supernode_status_proto_msgTypes[2] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -248,19 +264,22 @@ func (x *StatusResponse_Resources) GetHardwareSummary() string { // ServiceTasks contains task information for a specific service type StatusResponse_ServiceTasks struct { - state protoimpl.MessageState `protogen:"open.v1"` - ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` - TaskIds []string `protobuf:"bytes,2,rep,name=task_ids,json=taskIds,proto3" json:"task_ids,omitempty"` - TaskCount int32 `protobuf:"varint,3,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"` - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + TaskIds []string `protobuf:"bytes,2,rep,name=task_ids,json=taskIds,proto3" json:"task_ids,omitempty"` + TaskCount int32 `protobuf:"varint,3,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"` } func (x *StatusResponse_ServiceTasks) Reset() { *x = StatusResponse_ServiceTasks{} - mi := &file_supernode_status_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_ServiceTasks) String() string { @@ -271,7 +290,7 @@ func (*StatusResponse_ServiceTasks) ProtoMessage() {} func (x *StatusResponse_ServiceTasks) ProtoReflect() protoreflect.Message { mi := &file_supernode_status_proto_msgTypes[3] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -309,18 +328,21 @@ func (x *StatusResponse_ServiceTasks) GetTaskCount() int32 { // Network information type StatusResponse_Network struct { - state protoimpl.MessageState `protogen:"open.v1"` - PeersCount int32 `protobuf:"varint,1,opt,name=peers_count,json=peersCount,proto3" json:"peers_count,omitempty"` // Number of connected peers in P2P network - PeerAddresses []string `protobuf:"bytes,2,rep,name=peer_addresses,json=peerAddresses,proto3" json:"peer_addresses,omitempty"` // List of connected peer addresses (optional, may be empty for privacy) - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + PeersCount int32 `protobuf:"varint,1,opt,name=peers_count,json=peersCount,proto3" json:"peers_count,omitempty"` // Number of connected peers in P2P network + PeerAddresses []string `protobuf:"bytes,2,rep,name=peer_addresses,json=peerAddresses,proto3" json:"peer_addresses,omitempty"` // List of connected peer addresses (optional, may be empty for privacy) } func (x *StatusResponse_Network) Reset() { *x = StatusResponse_Network{} - mi := &file_supernode_status_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_Network) String() string { @@ -331,7 +353,7 @@ func (*StatusResponse_Network) ProtoMessage() {} func (x *StatusResponse_Network) ProtoReflect() protoreflect.Message { mi := &file_supernode_status_proto_msgTypes[4] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -362,22 +384,25 @@ func (x *StatusResponse_Network) GetPeerAddresses() []string { // P2P metrics and diagnostics (additive field) type StatusResponse_P2PMetrics struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + DhtMetrics *StatusResponse_P2PMetrics_DhtMetrics `protobuf:"bytes,1,opt,name=dht_metrics,json=dhtMetrics,proto3" json:"dht_metrics,omitempty"` - NetworkHandleMetrics map[string]*StatusResponse_P2PMetrics_HandleCounters `protobuf:"bytes,2,rep,name=network_handle_metrics,json=networkHandleMetrics,proto3" json:"network_handle_metrics,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - ConnPoolMetrics map[string]int64 `protobuf:"bytes,3,rep,name=conn_pool_metrics,json=connPoolMetrics,proto3" json:"conn_pool_metrics,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + NetworkHandleMetrics map[string]*StatusResponse_P2PMetrics_HandleCounters `protobuf:"bytes,2,rep,name=network_handle_metrics,json=networkHandleMetrics,proto3" json:"network_handle_metrics,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + ConnPoolMetrics map[string]int64 `protobuf:"bytes,3,rep,name=conn_pool_metrics,json=connPoolMetrics,proto3" json:"conn_pool_metrics,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` BanList []*StatusResponse_P2PMetrics_BanEntry `protobuf:"bytes,4,rep,name=ban_list,json=banList,proto3" json:"ban_list,omitempty"` Database *StatusResponse_P2PMetrics_DatabaseStats `protobuf:"bytes,5,opt,name=database,proto3" json:"database,omitempty"` Disk *StatusResponse_P2PMetrics_DiskStatus `protobuf:"bytes,6,opt,name=disk,proto3" json:"disk,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache } func (x *StatusResponse_P2PMetrics) Reset() { *x = StatusResponse_P2PMetrics{} - mi := &file_supernode_status_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_P2PMetrics) String() string { @@ -388,7 +413,7 @@ func (*StatusResponse_P2PMetrics) ProtoMessage() {} func (x *StatusResponse_P2PMetrics) ProtoReflect() protoreflect.Message { mi := &file_supernode_status_proto_msgTypes[5] - if x != nil { + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -445,19 +470,227 @@ func (x *StatusResponse_P2PMetrics) GetDisk() *StatusResponse_P2PMetrics_DiskSta return nil } -type StatusResponse_Resources_CPU struct { - state protoimpl.MessageState `protogen:"open.v1"` - UsagePercent float64 `protobuf:"fixed64,1,opt,name=usage_percent,json=usagePercent,proto3" json:"usage_percent,omitempty"` // CPU usage percentage (0-100) - Cores int32 `protobuf:"varint,2,opt,name=cores,proto3" json:"cores,omitempty"` // Number of CPU cores +// LEP-6 storage-truth runtime metrics and diagnostics. These are in-memory +// counters/gauges reset on process restart, matching the existing typed +// status-snapshot pattern used for P2P metrics. +type StatusResponse_LEP6Metrics struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields + + // Storage challenge / dispatch signals. + DispatchResultsTotal map[string]uint64 `protobuf:"bytes,1,rep,name=dispatch_results_total,json=dispatchResultsTotal,proto3" json:"dispatch_results_total,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + DispatchThrottledTotal map[string]uint64 `protobuf:"bytes,2,rep,name=dispatch_throttled_total,json=dispatchThrottledTotal,proto3" json:"dispatch_throttled_total,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + DispatchEpochDurationMillisTotal map[string]uint64 `protobuf:"bytes,3,rep,name=dispatch_epoch_duration_millis_total,json=dispatchEpochDurationMillisTotal,proto3" json:"dispatch_epoch_duration_millis_total,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + DispatchEpochDurationMillisMax map[string]uint64 `protobuf:"bytes,4,rep,name=dispatch_epoch_duration_millis_max,json=dispatchEpochDurationMillisMax,proto3" json:"dispatch_epoch_duration_millis_max,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + DispatchEpochDurationCount map[string]uint64 `protobuf:"bytes,5,rep,name=dispatch_epoch_duration_count,json=dispatchEpochDurationCount,proto3" json:"dispatch_epoch_duration_count,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + TicketDiscoveryTotal map[string]uint64 `protobuf:"bytes,6,rep,name=ticket_discovery_total,json=ticketDiscoveryTotal,proto3" json:"ticket_discovery_total,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + NoTicketProviderActive int64 `protobuf:"varint,7,opt,name=no_ticket_provider_active,json=noTicketProviderActive,proto3" json:"no_ticket_provider_active,omitempty"` + // Self-healing signals. + HealClaimsSubmittedTotal map[string]uint64 `protobuf:"bytes,8,rep,name=heal_claims_submitted_total,json=healClaimsSubmittedTotal,proto3" json:"heal_claims_submitted_total,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + HealClaimsReconciledTotal uint64 `protobuf:"varint,9,opt,name=heal_claims_reconciled_total,json=healClaimsReconciledTotal,proto3" json:"heal_claims_reconciled_total,omitempty"` + HealVerificationsSubmittedTotal map[string]uint64 `protobuf:"bytes,10,rep,name=heal_verifications_submitted_total,json=healVerificationsSubmittedTotal,proto3" json:"heal_verifications_submitted_total,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + HealVerificationsAlreadyExistsTotal uint64 `protobuf:"varint,11,opt,name=heal_verifications_already_exists_total,json=healVerificationsAlreadyExistsTotal,proto3" json:"heal_verifications_already_exists_total,omitempty"` + HealFinalizePublishesTotal uint64 `protobuf:"varint,12,opt,name=heal_finalize_publishes_total,json=healFinalizePublishesTotal,proto3" json:"heal_finalize_publishes_total,omitempty"` + HealFinalizeCleanupsTotal map[string]uint64 `protobuf:"bytes,13,rep,name=heal_finalize_cleanups_total,json=healFinalizeCleanupsTotal,proto3" json:"heal_finalize_cleanups_total,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + SelfHealingPendingClaims int64 `protobuf:"varint,14,opt,name=self_healing_pending_claims,json=selfHealingPendingClaims,proto3" json:"self_healing_pending_claims,omitempty"` + SelfHealingStagingBytes int64 `protobuf:"varint,15,opt,name=self_healing_staging_bytes,json=selfHealingStagingBytes,proto3" json:"self_healing_staging_bytes,omitempty"` + // Storage recheck signals. + RecheckCandidatesFoundTotal uint64 `protobuf:"varint,16,opt,name=recheck_candidates_found_total,json=recheckCandidatesFoundTotal,proto3" json:"recheck_candidates_found_total,omitempty"` + RecheckEvidenceSubmittedTotal map[string]uint64 `protobuf:"bytes,17,rep,name=recheck_evidence_submitted_total,json=recheckEvidenceSubmittedTotal,proto3" json:"recheck_evidence_submitted_total,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + RecheckEvidenceAlreadySubmittedTotal uint64 `protobuf:"varint,18,opt,name=recheck_evidence_already_submitted_total,json=recheckEvidenceAlreadySubmittedTotal,proto3" json:"recheck_evidence_already_submitted_total,omitempty"` + RecheckExecutionFailuresTotal map[string]uint64 `protobuf:"bytes,19,rep,name=recheck_execution_failures_total,json=recheckExecutionFailuresTotal,proto3" json:"recheck_execution_failures_total,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + RecheckPendingCandidates int64 `protobuf:"varint,20,opt,name=recheck_pending_candidates,json=recheckPendingCandidates,proto3" json:"recheck_pending_candidates,omitempty"` +} + +func (x *StatusResponse_LEP6Metrics) Reset() { + *x = StatusResponse_LEP6Metrics{} + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *StatusResponse_LEP6Metrics) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusResponse_LEP6Metrics) ProtoMessage() {} + +func (x *StatusResponse_LEP6Metrics) ProtoReflect() protoreflect.Message { + mi := &file_supernode_status_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusResponse_LEP6Metrics.ProtoReflect.Descriptor instead. +func (*StatusResponse_LEP6Metrics) Descriptor() ([]byte, []int) { + return file_supernode_status_proto_rawDescGZIP(), []int{1, 4} +} + +func (x *StatusResponse_LEP6Metrics) GetDispatchResultsTotal() map[string]uint64 { + if x != nil { + return x.DispatchResultsTotal + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetDispatchThrottledTotal() map[string]uint64 { + if x != nil { + return x.DispatchThrottledTotal + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetDispatchEpochDurationMillisTotal() map[string]uint64 { + if x != nil { + return x.DispatchEpochDurationMillisTotal + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetDispatchEpochDurationMillisMax() map[string]uint64 { + if x != nil { + return x.DispatchEpochDurationMillisMax + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetDispatchEpochDurationCount() map[string]uint64 { + if x != nil { + return x.DispatchEpochDurationCount + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetTicketDiscoveryTotal() map[string]uint64 { + if x != nil { + return x.TicketDiscoveryTotal + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetNoTicketProviderActive() int64 { + if x != nil { + return x.NoTicketProviderActive + } + return 0 +} + +func (x *StatusResponse_LEP6Metrics) GetHealClaimsSubmittedTotal() map[string]uint64 { + if x != nil { + return x.HealClaimsSubmittedTotal + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetHealClaimsReconciledTotal() uint64 { + if x != nil { + return x.HealClaimsReconciledTotal + } + return 0 +} + +func (x *StatusResponse_LEP6Metrics) GetHealVerificationsSubmittedTotal() map[string]uint64 { + if x != nil { + return x.HealVerificationsSubmittedTotal + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetHealVerificationsAlreadyExistsTotal() uint64 { + if x != nil { + return x.HealVerificationsAlreadyExistsTotal + } + return 0 +} + +func (x *StatusResponse_LEP6Metrics) GetHealFinalizePublishesTotal() uint64 { + if x != nil { + return x.HealFinalizePublishesTotal + } + return 0 +} + +func (x *StatusResponse_LEP6Metrics) GetHealFinalizeCleanupsTotal() map[string]uint64 { + if x != nil { + return x.HealFinalizeCleanupsTotal + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetSelfHealingPendingClaims() int64 { + if x != nil { + return x.SelfHealingPendingClaims + } + return 0 +} + +func (x *StatusResponse_LEP6Metrics) GetSelfHealingStagingBytes() int64 { + if x != nil { + return x.SelfHealingStagingBytes + } + return 0 +} + +func (x *StatusResponse_LEP6Metrics) GetRecheckCandidatesFoundTotal() uint64 { + if x != nil { + return x.RecheckCandidatesFoundTotal + } + return 0 +} + +func (x *StatusResponse_LEP6Metrics) GetRecheckEvidenceSubmittedTotal() map[string]uint64 { + if x != nil { + return x.RecheckEvidenceSubmittedTotal + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetRecheckEvidenceAlreadySubmittedTotal() uint64 { + if x != nil { + return x.RecheckEvidenceAlreadySubmittedTotal + } + return 0 +} + +func (x *StatusResponse_LEP6Metrics) GetRecheckExecutionFailuresTotal() map[string]uint64 { + if x != nil { + return x.RecheckExecutionFailuresTotal + } + return nil +} + +func (x *StatusResponse_LEP6Metrics) GetRecheckPendingCandidates() int64 { + if x != nil { + return x.RecheckPendingCandidates + } + return 0 +} + +type StatusResponse_Resources_CPU struct { + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UsagePercent float64 `protobuf:"fixed64,1,opt,name=usage_percent,json=usagePercent,proto3" json:"usage_percent,omitempty"` // CPU usage percentage (0-100) + Cores int32 `protobuf:"varint,2,opt,name=cores,proto3" json:"cores,omitempty"` // Number of CPU cores } func (x *StatusResponse_Resources_CPU) Reset() { *x = StatusResponse_Resources_CPU{} - mi := &file_supernode_status_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_Resources_CPU) String() string { @@ -467,8 +700,8 @@ func (x *StatusResponse_Resources_CPU) String() string { func (*StatusResponse_Resources_CPU) ProtoMessage() {} func (x *StatusResponse_Resources_CPU) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[6] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -498,20 +731,23 @@ func (x *StatusResponse_Resources_CPU) GetCores() int32 { } type StatusResponse_Resources_Memory struct { - state protoimpl.MessageState `protogen:"open.v1"` - TotalGb float64 `protobuf:"fixed64,1,opt,name=total_gb,json=totalGb,proto3" json:"total_gb,omitempty"` // Total memory in GB - UsedGb float64 `protobuf:"fixed64,2,opt,name=used_gb,json=usedGb,proto3" json:"used_gb,omitempty"` // Used memory in GB - AvailableGb float64 `protobuf:"fixed64,3,opt,name=available_gb,json=availableGb,proto3" json:"available_gb,omitempty"` // Available memory in GB - UsagePercent float64 `protobuf:"fixed64,4,opt,name=usage_percent,json=usagePercent,proto3" json:"usage_percent,omitempty"` // Memory usage percentage (0-100) - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TotalGb float64 `protobuf:"fixed64,1,opt,name=total_gb,json=totalGb,proto3" json:"total_gb,omitempty"` // Total memory in GB + UsedGb float64 `protobuf:"fixed64,2,opt,name=used_gb,json=usedGb,proto3" json:"used_gb,omitempty"` // Used memory in GB + AvailableGb float64 `protobuf:"fixed64,3,opt,name=available_gb,json=availableGb,proto3" json:"available_gb,omitempty"` // Available memory in GB + UsagePercent float64 `protobuf:"fixed64,4,opt,name=usage_percent,json=usagePercent,proto3" json:"usage_percent,omitempty"` // Memory usage percentage (0-100) } func (x *StatusResponse_Resources_Memory) Reset() { *x = StatusResponse_Resources_Memory{} - mi := &file_supernode_status_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_Resources_Memory) String() string { @@ -521,8 +757,8 @@ func (x *StatusResponse_Resources_Memory) String() string { func (*StatusResponse_Resources_Memory) ProtoMessage() {} func (x *StatusResponse_Resources_Memory) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[7] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -566,21 +802,24 @@ func (x *StatusResponse_Resources_Memory) GetUsagePercent() float64 { } type StatusResponse_Resources_Storage struct { - state protoimpl.MessageState `protogen:"open.v1"` - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // Storage path being monitored - TotalBytes uint64 `protobuf:"varint,2,opt,name=total_bytes,json=totalBytes,proto3" json:"total_bytes,omitempty"` - UsedBytes uint64 `protobuf:"varint,3,opt,name=used_bytes,json=usedBytes,proto3" json:"used_bytes,omitempty"` - AvailableBytes uint64 `protobuf:"varint,4,opt,name=available_bytes,json=availableBytes,proto3" json:"available_bytes,omitempty"` - UsagePercent float64 `protobuf:"fixed64,5,opt,name=usage_percent,json=usagePercent,proto3" json:"usage_percent,omitempty"` // Storage usage percentage (0-100) - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // Storage path being monitored + TotalBytes uint64 `protobuf:"varint,2,opt,name=total_bytes,json=totalBytes,proto3" json:"total_bytes,omitempty"` + UsedBytes uint64 `protobuf:"varint,3,opt,name=used_bytes,json=usedBytes,proto3" json:"used_bytes,omitempty"` + AvailableBytes uint64 `protobuf:"varint,4,opt,name=available_bytes,json=availableBytes,proto3" json:"available_bytes,omitempty"` + UsagePercent float64 `protobuf:"fixed64,5,opt,name=usage_percent,json=usagePercent,proto3" json:"usage_percent,omitempty"` // Storage usage percentage (0-100) } func (x *StatusResponse_Resources_Storage) Reset() { *x = StatusResponse_Resources_Storage{} - mi := &file_supernode_status_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_Resources_Storage) String() string { @@ -590,8 +829,8 @@ func (x *StatusResponse_Resources_Storage) String() string { func (*StatusResponse_Resources_Storage) ProtoMessage() {} func (x *StatusResponse_Resources_Storage) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[8] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -643,20 +882,23 @@ func (x *StatusResponse_Resources_Storage) GetUsagePercent() float64 { // Rolling DHT metrics snapshot type StatusResponse_P2PMetrics_DhtMetrics struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + StoreSuccessRecent []*StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint `protobuf:"bytes,1,rep,name=store_success_recent,json=storeSuccessRecent,proto3" json:"store_success_recent,omitempty"` BatchRetrieveRecent []*StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint `protobuf:"bytes,2,rep,name=batch_retrieve_recent,json=batchRetrieveRecent,proto3" json:"batch_retrieve_recent,omitempty"` HotPathBannedSkips int64 `protobuf:"varint,3,opt,name=hot_path_banned_skips,json=hotPathBannedSkips,proto3" json:"hot_path_banned_skips,omitempty"` // counter HotPathBanIncrements int64 `protobuf:"varint,4,opt,name=hot_path_ban_increments,json=hotPathBanIncrements,proto3" json:"hot_path_ban_increments,omitempty"` // counter - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache } func (x *StatusResponse_P2PMetrics_DhtMetrics) Reset() { *x = StatusResponse_P2PMetrics_DhtMetrics{} - mi := &file_supernode_status_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_P2PMetrics_DhtMetrics) String() string { @@ -666,8 +908,8 @@ func (x *StatusResponse_P2PMetrics_DhtMetrics) String() string { func (*StatusResponse_P2PMetrics_DhtMetrics) ProtoMessage() {} func (x *StatusResponse_P2PMetrics_DhtMetrics) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[9] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -712,20 +954,23 @@ func (x *StatusResponse_P2PMetrics_DhtMetrics) GetHotPathBanIncrements() int64 { // Per-handler counters from network layer type StatusResponse_P2PMetrics_HandleCounters struct { - state protoimpl.MessageState `protogen:"open.v1"` - Total int64 `protobuf:"varint,1,opt,name=total,proto3" json:"total,omitempty"` - Success int64 `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` - Failure int64 `protobuf:"varint,3,opt,name=failure,proto3" json:"failure,omitempty"` - Timeout int64 `protobuf:"varint,4,opt,name=timeout,proto3" json:"timeout,omitempty"` - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Total int64 `protobuf:"varint,1,opt,name=total,proto3" json:"total,omitempty"` + Success int64 `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` + Failure int64 `protobuf:"varint,3,opt,name=failure,proto3" json:"failure,omitempty"` + Timeout int64 `protobuf:"varint,4,opt,name=timeout,proto3" json:"timeout,omitempty"` } func (x *StatusResponse_P2PMetrics_HandleCounters) Reset() { *x = StatusResponse_P2PMetrics_HandleCounters{} - mi := &file_supernode_status_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_P2PMetrics_HandleCounters) String() string { @@ -735,8 +980,8 @@ func (x *StatusResponse_P2PMetrics_HandleCounters) String() string { func (*StatusResponse_P2PMetrics_HandleCounters) ProtoMessage() {} func (x *StatusResponse_P2PMetrics_HandleCounters) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[10] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -781,22 +1026,25 @@ func (x *StatusResponse_P2PMetrics_HandleCounters) GetTimeout() int64 { // Ban list entry type StatusResponse_P2PMetrics_BanEntry struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // printable ID - Ip string `protobuf:"bytes,2,opt,name=ip,proto3" json:"ip,omitempty"` // last seen IP - Port uint32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` // last seen port - Count int32 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"` // failure count - CreatedAtUnix int64 `protobuf:"varint,5,opt,name=created_at_unix,json=createdAtUnix,proto3" json:"created_at_unix,omitempty"` // first ban time (unix seconds) - AgeSeconds int64 `protobuf:"varint,6,opt,name=age_seconds,json=ageSeconds,proto3" json:"age_seconds,omitempty"` // age in seconds - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // printable ID + Ip string `protobuf:"bytes,2,opt,name=ip,proto3" json:"ip,omitempty"` // last seen IP + Port uint32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` // last seen port + Count int32 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"` // failure count + CreatedAtUnix int64 `protobuf:"varint,5,opt,name=created_at_unix,json=createdAtUnix,proto3" json:"created_at_unix,omitempty"` // first ban time (unix seconds) + AgeSeconds int64 `protobuf:"varint,6,opt,name=age_seconds,json=ageSeconds,proto3" json:"age_seconds,omitempty"` // age in seconds } func (x *StatusResponse_P2PMetrics_BanEntry) Reset() { *x = StatusResponse_P2PMetrics_BanEntry{} - mi := &file_supernode_status_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_P2PMetrics_BanEntry) String() string { @@ -806,8 +1054,8 @@ func (x *StatusResponse_P2PMetrics_BanEntry) String() string { func (*StatusResponse_P2PMetrics_BanEntry) ProtoMessage() {} func (x *StatusResponse_P2PMetrics_BanEntry) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[11] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -866,18 +1114,21 @@ func (x *StatusResponse_P2PMetrics_BanEntry) GetAgeSeconds() int64 { // DB stats type StatusResponse_P2PMetrics_DatabaseStats struct { - state protoimpl.MessageState `protogen:"open.v1"` - P2PDbSizeMb float64 `protobuf:"fixed64,1,opt,name=p2p_db_size_mb,json=p2pDbSizeMb,proto3" json:"p2p_db_size_mb,omitempty"` - P2PDbRecordsCount int64 `protobuf:"varint,2,opt,name=p2p_db_records_count,json=p2pDbRecordsCount,proto3" json:"p2p_db_records_count,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + P2PDbSizeMb float64 `protobuf:"fixed64,1,opt,name=p2p_db_size_mb,json=p2pDbSizeMb,proto3" json:"p2p_db_size_mb,omitempty"` + P2PDbRecordsCount int64 `protobuf:"varint,2,opt,name=p2p_db_records_count,json=p2pDbRecordsCount,proto3" json:"p2p_db_records_count,omitempty"` } func (x *StatusResponse_P2PMetrics_DatabaseStats) Reset() { *x = StatusResponse_P2PMetrics_DatabaseStats{} - mi := &file_supernode_status_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_P2PMetrics_DatabaseStats) String() string { @@ -887,8 +1138,8 @@ func (x *StatusResponse_P2PMetrics_DatabaseStats) String() string { func (*StatusResponse_P2PMetrics_DatabaseStats) ProtoMessage() {} func (x *StatusResponse_P2PMetrics_DatabaseStats) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[12] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -919,19 +1170,22 @@ func (x *StatusResponse_P2PMetrics_DatabaseStats) GetP2PDbRecordsCount() int64 { // Disk status type StatusResponse_P2PMetrics_DiskStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - AllMb float64 `protobuf:"fixed64,1,opt,name=all_mb,json=allMb,proto3" json:"all_mb,omitempty"` - UsedMb float64 `protobuf:"fixed64,2,opt,name=used_mb,json=usedMb,proto3" json:"used_mb,omitempty"` - FreeMb float64 `protobuf:"fixed64,3,opt,name=free_mb,json=freeMb,proto3" json:"free_mb,omitempty"` - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AllMb float64 `protobuf:"fixed64,1,opt,name=all_mb,json=allMb,proto3" json:"all_mb,omitempty"` + UsedMb float64 `protobuf:"fixed64,2,opt,name=used_mb,json=usedMb,proto3" json:"used_mb,omitempty"` + FreeMb float64 `protobuf:"fixed64,3,opt,name=free_mb,json=freeMb,proto3" json:"free_mb,omitempty"` } func (x *StatusResponse_P2PMetrics_DiskStatus) Reset() { *x = StatusResponse_P2PMetrics_DiskStatus{} - mi := &file_supernode_status_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_P2PMetrics_DiskStatus) String() string { @@ -941,8 +1195,8 @@ func (x *StatusResponse_P2PMetrics_DiskStatus) String() string { func (*StatusResponse_P2PMetrics_DiskStatus) ProtoMessage() {} func (x *StatusResponse_P2PMetrics_DiskStatus) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[13] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -979,20 +1233,23 @@ func (x *StatusResponse_P2PMetrics_DiskStatus) GetFreeMb() float64 { } type StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint struct { - state protoimpl.MessageState `protogen:"open.v1"` - TimeUnix int64 `protobuf:"varint,1,opt,name=time_unix,json=timeUnix,proto3" json:"time_unix,omitempty"` // event time (unix seconds) - Requests int32 `protobuf:"varint,2,opt,name=requests,proto3" json:"requests,omitempty"` // total node RPCs attempted - Successful int32 `protobuf:"varint,3,opt,name=successful,proto3" json:"successful,omitempty"` // successful node RPCs - SuccessRate float64 `protobuf:"fixed64,4,opt,name=success_rate,json=successRate,proto3" json:"success_rate,omitempty"` // percentage (0-100) - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TimeUnix int64 `protobuf:"varint,1,opt,name=time_unix,json=timeUnix,proto3" json:"time_unix,omitempty"` // event time (unix seconds) + Requests int32 `protobuf:"varint,2,opt,name=requests,proto3" json:"requests,omitempty"` // total node RPCs attempted + Successful int32 `protobuf:"varint,3,opt,name=successful,proto3" json:"successful,omitempty"` // successful node RPCs + SuccessRate float64 `protobuf:"fixed64,4,opt,name=success_rate,json=successRate,proto3" json:"success_rate,omitempty"` // percentage (0-100) } func (x *StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint) Reset() { *x = StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint{} - mi := &file_supernode_status_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint) String() string { @@ -1002,8 +1259,8 @@ func (x *StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint) String() string func (*StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint) ProtoMessage() {} func (x *StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[16] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1047,22 +1304,25 @@ func (x *StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint) GetSuccessRate( } type StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint struct { - state protoimpl.MessageState `protogen:"open.v1"` - TimeUnix int64 `protobuf:"varint,1,opt,name=time_unix,json=timeUnix,proto3" json:"time_unix,omitempty"` // event time (unix seconds) - Keys int32 `protobuf:"varint,2,opt,name=keys,proto3" json:"keys,omitempty"` // keys requested - Required int32 `protobuf:"varint,3,opt,name=required,proto3" json:"required,omitempty"` // required count - FoundLocal int32 `protobuf:"varint,4,opt,name=found_local,json=foundLocal,proto3" json:"found_local,omitempty"` // found locally - FoundNetwork int32 `protobuf:"varint,5,opt,name=found_network,json=foundNetwork,proto3" json:"found_network,omitempty"` // found on network - DurationMs int64 `protobuf:"varint,6,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` // duration in milliseconds - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TimeUnix int64 `protobuf:"varint,1,opt,name=time_unix,json=timeUnix,proto3" json:"time_unix,omitempty"` // event time (unix seconds) + Keys int32 `protobuf:"varint,2,opt,name=keys,proto3" json:"keys,omitempty"` // keys requested + Required int32 `protobuf:"varint,3,opt,name=required,proto3" json:"required,omitempty"` // required count + FoundLocal int32 `protobuf:"varint,4,opt,name=found_local,json=foundLocal,proto3" json:"found_local,omitempty"` // found locally + FoundNetwork int32 `protobuf:"varint,5,opt,name=found_network,json=foundNetwork,proto3" json:"found_network,omitempty"` // found on network + DurationMs int64 `protobuf:"varint,6,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` // duration in milliseconds } func (x *StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint) Reset() { *x = StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint{} - mi := &file_supernode_status_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_status_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint) String() string { @@ -1072,8 +1332,8 @@ func (x *StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint) String() strin func (*StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint) ProtoMessage() {} func (x *StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint) ProtoReflect() protoreflect.Message { - mi := &file_supernode_status_proto_msgTypes[17] - if x != nil { + mi := &file_supernode_status_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1132,125 +1392,428 @@ func (x *StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint) GetDurationMs( var File_supernode_status_proto protoreflect.FileDescriptor -const file_supernode_status_proto_rawDesc = "" + - "\n" + - "\x16supernode/status.proto\x12\tsupernode\"?\n" + - "\rStatusRequest\x12.\n" + - "\x13include_p2p_metrics\x18\x01 \x01(\bR\x11includeP2pMetrics\"\x84\x19\n" + - "\x0eStatusResponse\x12\x18\n" + - "\aversion\x18\x01 \x01(\tR\aversion\x12%\n" + - "\x0euptime_seconds\x18\x02 \x01(\x04R\ruptimeSeconds\x12A\n" + - "\tresources\x18\x03 \x01(\v2#.supernode.StatusResponse.ResourcesR\tresources\x12K\n" + - "\rrunning_tasks\x18\x04 \x03(\v2&.supernode.StatusResponse.ServiceTasksR\frunningTasks\x12/\n" + - "\x13registered_services\x18\x05 \x03(\tR\x12registeredServices\x12;\n" + - "\anetwork\x18\x06 \x01(\v2!.supernode.StatusResponse.NetworkR\anetwork\x12\x12\n" + - "\x04rank\x18\a \x01(\x05R\x04rank\x12\x1d\n" + - "\n" + - "ip_address\x18\b \x01(\tR\tipAddress\x12E\n" + - "\vp2p_metrics\x18\t \x01(\v2$.supernode.StatusResponse.P2PMetricsR\n" + - "p2pMetrics\x1a\x82\x05\n" + - "\tResources\x129\n" + - "\x03cpu\x18\x01 \x01(\v2'.supernode.StatusResponse.Resources.CPUR\x03cpu\x12B\n" + - "\x06memory\x18\x02 \x01(\v2*.supernode.StatusResponse.Resources.MemoryR\x06memory\x12T\n" + - "\x0fstorage_volumes\x18\x03 \x03(\v2+.supernode.StatusResponse.Resources.StorageR\x0estorageVolumes\x12)\n" + - "\x10hardware_summary\x18\x04 \x01(\tR\x0fhardwareSummary\x1a@\n" + - "\x03CPU\x12#\n" + - "\rusage_percent\x18\x01 \x01(\x01R\fusagePercent\x12\x14\n" + - "\x05cores\x18\x02 \x01(\x05R\x05cores\x1a\x84\x01\n" + - "\x06Memory\x12\x19\n" + - "\btotal_gb\x18\x01 \x01(\x01R\atotalGb\x12\x17\n" + - "\aused_gb\x18\x02 \x01(\x01R\x06usedGb\x12!\n" + - "\favailable_gb\x18\x03 \x01(\x01R\vavailableGb\x12#\n" + - "\rusage_percent\x18\x04 \x01(\x01R\fusagePercent\x1a\xab\x01\n" + - "\aStorage\x12\x12\n" + - "\x04path\x18\x01 \x01(\tR\x04path\x12\x1f\n" + - "\vtotal_bytes\x18\x02 \x01(\x04R\n" + - "totalBytes\x12\x1d\n" + - "\n" + - "used_bytes\x18\x03 \x01(\x04R\tusedBytes\x12'\n" + - "\x0favailable_bytes\x18\x04 \x01(\x04R\x0eavailableBytes\x12#\n" + - "\rusage_percent\x18\x05 \x01(\x01R\fusagePercent\x1ak\n" + - "\fServiceTasks\x12!\n" + - "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x19\n" + - "\btask_ids\x18\x02 \x03(\tR\ataskIds\x12\x1d\n" + - "\n" + - "task_count\x18\x03 \x01(\x05R\ttaskCount\x1aQ\n" + - "\aNetwork\x12\x1f\n" + - "\vpeers_count\x18\x01 \x01(\x05R\n" + - "peersCount\x12%\n" + - "\x0epeer_addresses\x18\x02 \x03(\tR\rpeerAddresses\x1a\xf3\x0e\n" + - "\n" + - "P2PMetrics\x12P\n" + - "\vdht_metrics\x18\x01 \x01(\v2/.supernode.StatusResponse.P2PMetrics.DhtMetricsR\n" + - "dhtMetrics\x12t\n" + - "\x16network_handle_metrics\x18\x02 \x03(\v2>.supernode.StatusResponse.P2PMetrics.NetworkHandleMetricsEntryR\x14networkHandleMetrics\x12e\n" + - "\x11conn_pool_metrics\x18\x03 \x03(\v29.supernode.StatusResponse.P2PMetrics.ConnPoolMetricsEntryR\x0fconnPoolMetrics\x12H\n" + - "\bban_list\x18\x04 \x03(\v2-.supernode.StatusResponse.P2PMetrics.BanEntryR\abanList\x12N\n" + - "\bdatabase\x18\x05 \x01(\v22.supernode.StatusResponse.P2PMetrics.DatabaseStatsR\bdatabase\x12C\n" + - "\x04disk\x18\x06 \x01(\v2/.supernode.StatusResponse.P2PMetrics.DiskStatusR\x04disk\x1a\xc0\x05\n" + - "\n" + - "DhtMetrics\x12s\n" + - "\x14store_success_recent\x18\x01 \x03(\v2A.supernode.StatusResponse.P2PMetrics.DhtMetrics.StoreSuccessPointR\x12storeSuccessRecent\x12v\n" + - "\x15batch_retrieve_recent\x18\x02 \x03(\v2B.supernode.StatusResponse.P2PMetrics.DhtMetrics.BatchRetrievePointR\x13batchRetrieveRecent\x121\n" + - "\x15hot_path_banned_skips\x18\x03 \x01(\x03R\x12hotPathBannedSkips\x125\n" + - "\x17hot_path_ban_increments\x18\x04 \x01(\x03R\x14hotPathBanIncrements\x1a\x8f\x01\n" + - "\x11StoreSuccessPoint\x12\x1b\n" + - "\ttime_unix\x18\x01 \x01(\x03R\btimeUnix\x12\x1a\n" + - "\brequests\x18\x02 \x01(\x05R\brequests\x12\x1e\n" + - "\n" + - "successful\x18\x03 \x01(\x05R\n" + - "successful\x12!\n" + - "\fsuccess_rate\x18\x04 \x01(\x01R\vsuccessRate\x1a\xc8\x01\n" + - "\x12BatchRetrievePoint\x12\x1b\n" + - "\ttime_unix\x18\x01 \x01(\x03R\btimeUnix\x12\x12\n" + - "\x04keys\x18\x02 \x01(\x05R\x04keys\x12\x1a\n" + - "\brequired\x18\x03 \x01(\x05R\brequired\x12\x1f\n" + - "\vfound_local\x18\x04 \x01(\x05R\n" + - "foundLocal\x12#\n" + - "\rfound_network\x18\x05 \x01(\x05R\ffoundNetwork\x12\x1f\n" + - "\vduration_ms\x18\x06 \x01(\x03R\n" + - "durationMs\x1at\n" + - "\x0eHandleCounters\x12\x14\n" + - "\x05total\x18\x01 \x01(\x03R\x05total\x12\x18\n" + - "\asuccess\x18\x02 \x01(\x03R\asuccess\x12\x18\n" + - "\afailure\x18\x03 \x01(\x03R\afailure\x12\x18\n" + - "\atimeout\x18\x04 \x01(\x03R\atimeout\x1a\x9d\x01\n" + - "\bBanEntry\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x0e\n" + - "\x02ip\x18\x02 \x01(\tR\x02ip\x12\x12\n" + - "\x04port\x18\x03 \x01(\rR\x04port\x12\x14\n" + - "\x05count\x18\x04 \x01(\x05R\x05count\x12&\n" + - "\x0fcreated_at_unix\x18\x05 \x01(\x03R\rcreatedAtUnix\x12\x1f\n" + - "\vage_seconds\x18\x06 \x01(\x03R\n" + - "ageSeconds\x1ae\n" + - "\rDatabaseStats\x12#\n" + - "\x0ep2p_db_size_mb\x18\x01 \x01(\x01R\vp2pDbSizeMb\x12/\n" + - "\x14p2p_db_records_count\x18\x02 \x01(\x03R\x11p2pDbRecordsCount\x1aU\n" + - "\n" + - "DiskStatus\x12\x15\n" + - "\x06all_mb\x18\x01 \x01(\x01R\x05allMb\x12\x17\n" + - "\aused_mb\x18\x02 \x01(\x01R\x06usedMb\x12\x17\n" + - "\afree_mb\x18\x03 \x01(\x01R\x06freeMb\x1a|\n" + - "\x19NetworkHandleMetricsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12I\n" + - "\x05value\x18\x02 \x01(\v23.supernode.StatusResponse.P2PMetrics.HandleCountersR\x05value:\x028\x01\x1aB\n" + - "\x14ConnPoolMetricsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01B6Z4github.com/LumeraProtocol/supernode/v2/gen/supernodeb\x06proto3" +var file_supernode_status_proto_rawDesc = []byte{ + 0x0a, 0x16, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, + 0x6f, 0x64, 0x65, 0x22, 0x3f, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x13, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, + 0x70, 0x32, 0x70, 0x5f, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x11, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x50, 0x32, 0x70, 0x4d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x73, 0x22, 0xac, 0x31, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x73, 0x65, 0x63, 0x6f, + 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x75, 0x70, 0x74, 0x69, 0x6d, + 0x65, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x41, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x73, 0x75, + 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, + 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x0d, 0x72, + 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x04, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x0c, 0x72, 0x75, 0x6e, 0x6e, + 0x69, 0x6e, 0x67, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x2f, 0x0a, 0x13, 0x72, 0x65, 0x67, 0x69, + 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x18, + 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x65, + 0x64, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x07, 0x6e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x73, 0x75, 0x70, + 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x07, 0x6e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x61, 0x6e, 0x6b, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x72, 0x61, 0x6e, 0x6b, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x70, + 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x69, 0x70, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x45, 0x0a, 0x0b, 0x70, 0x32, 0x70, + 0x5f, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, + 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x32, 0x50, 0x4d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x73, 0x52, 0x0a, 0x70, 0x32, 0x70, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, + 0x12, 0x48, 0x0a, 0x0c, 0x6c, 0x65, 0x70, 0x36, 0x5f, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, + 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, + 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x0b, 0x6c, + 0x65, 0x70, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x1a, 0x82, 0x05, 0x0a, 0x09, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x39, 0x0a, 0x03, 0x63, 0x70, 0x75, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, + 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x43, 0x50, 0x55, 0x52, 0x03, + 0x63, 0x70, 0x75, 0x12, 0x42, 0x0a, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x4d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x52, + 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x12, 0x54, 0x0a, 0x0f, 0x73, 0x74, 0x6f, 0x72, 0x61, + 0x67, 0x65, 0x5f, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x0e, 0x73, + 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x12, 0x29, 0x0a, + 0x10, 0x68, 0x61, 0x72, 0x64, 0x77, 0x61, 0x72, 0x65, 0x5f, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, + 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x68, 0x61, 0x72, 0x64, 0x77, 0x61, 0x72, + 0x65, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x1a, 0x40, 0x0a, 0x03, 0x43, 0x50, 0x55, 0x12, + 0x23, 0x0a, 0x0d, 0x75, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0c, 0x75, 0x73, 0x61, 0x67, 0x65, 0x50, 0x65, 0x72, + 0x63, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x72, 0x65, 0x73, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x05, 0x63, 0x6f, 0x72, 0x65, 0x73, 0x1a, 0x84, 0x01, 0x0a, 0x06, 0x4d, + 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x67, + 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x47, 0x62, + 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x64, 0x5f, 0x67, 0x62, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x01, 0x52, 0x06, 0x75, 0x73, 0x65, 0x64, 0x47, 0x62, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x76, 0x61, + 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x67, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, + 0x0b, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x47, 0x62, 0x12, 0x23, 0x0a, 0x0d, + 0x75, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x01, 0x52, 0x0c, 0x75, 0x73, 0x61, 0x67, 0x65, 0x50, 0x65, 0x72, 0x63, 0x65, 0x6e, + 0x74, 0x1a, 0xab, 0x01, 0x0a, 0x07, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, + 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x42, 0x79, 0x74, + 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x75, 0x73, 0x65, 0x64, 0x42, 0x79, 0x74, 0x65, + 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x62, + 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0e, 0x61, 0x76, 0x61, 0x69, + 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x75, 0x73, + 0x61, 0x67, 0x65, 0x5f, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x01, 0x52, 0x0c, 0x75, 0x73, 0x61, 0x67, 0x65, 0x50, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x1a, + 0x6b, 0x0a, 0x0c, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, + 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, + 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x73, 0x12, 0x1d, 0x0a, + 0x0a, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x09, 0x74, 0x61, 0x73, 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x1a, 0x51, 0x0a, 0x07, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x73, + 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x70, 0x65, + 0x65, 0x72, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x0d, 0x70, 0x65, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x1a, + 0xf3, 0x0e, 0x0a, 0x0a, 0x50, 0x32, 0x50, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x12, 0x50, + 0x0a, 0x0b, 0x64, 0x68, 0x74, 0x5f, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, + 0x32, 0x50, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x44, 0x68, 0x74, 0x4d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x73, 0x52, 0x0a, 0x64, 0x68, 0x74, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, + 0x12, 0x74, 0x0a, 0x16, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x68, 0x61, 0x6e, 0x64, + 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x3e, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x32, 0x50, 0x4d, + 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x48, 0x61, + 0x6e, 0x64, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x14, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x48, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x4d, + 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x12, 0x65, 0x0a, 0x11, 0x63, 0x6f, 0x6e, 0x6e, 0x5f, 0x70, + 0x6f, 0x6f, 0x6c, 0x5f, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x39, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x32, 0x50, + 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x50, 0x6f, 0x6f, 0x6c, + 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0f, 0x63, 0x6f, + 0x6e, 0x6e, 0x50, 0x6f, 0x6f, 0x6c, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, + 0x08, 0x62, 0x61, 0x6e, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x2d, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x32, 0x50, 0x4d, 0x65, + 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x42, 0x61, 0x6e, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, + 0x62, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x4e, 0x0a, 0x08, 0x64, 0x61, 0x74, 0x61, 0x62, + 0x61, 0x73, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x73, 0x75, 0x70, 0x65, + 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x32, 0x50, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, + 0x44, 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x08, 0x64, + 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x04, 0x64, 0x69, 0x73, 0x6b, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, + 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x2e, 0x50, 0x32, 0x50, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x44, 0x69, 0x73, 0x6b, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x04, 0x64, 0x69, 0x73, 0x6b, 0x1a, 0xc0, 0x05, 0x0a, + 0x0a, 0x44, 0x68, 0x74, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x12, 0x73, 0x0a, 0x14, 0x73, + 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x72, 0x65, 0x63, + 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x41, 0x2e, 0x73, 0x75, 0x70, 0x65, + 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x32, 0x50, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, + 0x44, 0x68, 0x74, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x53, 0x74, 0x6f, 0x72, 0x65, + 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x52, 0x12, 0x73, 0x74, + 0x6f, 0x72, 0x65, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x63, 0x65, 0x6e, 0x74, + 0x12, 0x76, 0x0a, 0x15, 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x72, 0x65, 0x74, 0x72, 0x69, 0x65, + 0x76, 0x65, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x42, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x32, 0x50, 0x4d, 0x65, + 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x44, 0x68, 0x74, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, + 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x74, 0x72, 0x69, 0x65, 0x76, 0x65, 0x50, 0x6f, + 0x69, 0x6e, 0x74, 0x52, 0x13, 0x62, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x74, 0x72, 0x69, 0x65, + 0x76, 0x65, 0x52, 0x65, 0x63, 0x65, 0x6e, 0x74, 0x12, 0x31, 0x0a, 0x15, 0x68, 0x6f, 0x74, 0x5f, + 0x70, 0x61, 0x74, 0x68, 0x5f, 0x62, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x5f, 0x73, 0x6b, 0x69, 0x70, + 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x68, 0x6f, 0x74, 0x50, 0x61, 0x74, 0x68, + 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x53, 0x6b, 0x69, 0x70, 0x73, 0x12, 0x35, 0x0a, 0x17, 0x68, + 0x6f, 0x74, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x5f, 0x62, 0x61, 0x6e, 0x5f, 0x69, 0x6e, 0x63, 0x72, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x68, 0x6f, + 0x74, 0x50, 0x61, 0x74, 0x68, 0x42, 0x61, 0x6e, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x73, 0x1a, 0x8f, 0x01, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x53, 0x75, 0x63, 0x63, + 0x65, 0x73, 0x73, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, + 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x69, 0x6d, + 0x65, 0x55, 0x6e, 0x69, 0x78, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x66, 0x75, 0x6c, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x66, 0x75, + 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x72, 0x61, 0x74, + 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0b, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x52, 0x61, 0x74, 0x65, 0x1a, 0xc8, 0x01, 0x0a, 0x12, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, + 0x74, 0x72, 0x69, 0x65, 0x76, 0x65, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, + 0x69, 0x6d, 0x65, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, + 0x74, 0x69, 0x6d, 0x65, 0x55, 0x6e, 0x69, 0x78, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x65, 0x79, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x12, 0x1a, 0x0a, 0x08, + 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, + 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x66, 0x6f, 0x75, 0x6e, + 0x64, 0x5f, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x66, + 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x6f, 0x75, + 0x6e, 0x64, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x0c, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x1f, + 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x1a, + 0x74, 0x0a, 0x0e, 0x48, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, + 0x73, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, + 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x12, 0x18, 0x0a, 0x07, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x07, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x74, + 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x74, 0x69, + 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x1a, 0x9d, 0x01, 0x0a, 0x08, 0x42, 0x61, 0x6e, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x0f, + 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, + 0x55, 0x6e, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x65, 0x63, 0x6f, + 0x6e, 0x64, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x61, 0x67, 0x65, 0x53, 0x65, + 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x1a, 0x65, 0x0a, 0x0d, 0x44, 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, + 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x23, 0x0a, 0x0e, 0x70, 0x32, 0x70, 0x5f, 0x64, 0x62, + 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x6d, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0b, + 0x70, 0x32, 0x70, 0x44, 0x62, 0x53, 0x69, 0x7a, 0x65, 0x4d, 0x62, 0x12, 0x2f, 0x0a, 0x14, 0x70, + 0x32, 0x70, 0x5f, 0x64, 0x62, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x5f, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x70, 0x32, 0x70, 0x44, 0x62, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x1a, 0x55, 0x0a, 0x0a, + 0x44, 0x69, 0x73, 0x6b, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x15, 0x0a, 0x06, 0x61, 0x6c, + 0x6c, 0x5f, 0x6d, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x61, 0x6c, 0x6c, 0x4d, + 0x62, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x64, 0x5f, 0x6d, 0x62, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x01, 0x52, 0x06, 0x75, 0x73, 0x65, 0x64, 0x4d, 0x62, 0x12, 0x17, 0x0a, 0x07, 0x66, 0x72, + 0x65, 0x65, 0x5f, 0x6d, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, 0x06, 0x66, 0x72, 0x65, + 0x65, 0x4d, 0x62, 0x1a, 0x7c, 0x0a, 0x19, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x48, 0x61, + 0x6e, 0x64, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x49, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x33, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x32, 0x50, + 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x48, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x1a, 0x42, 0x0a, 0x14, 0x43, 0x6f, 0x6e, 0x6e, 0x50, 0x6f, 0x6f, 0x6c, 0x4d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0xdb, 0x17, 0x0a, 0x0b, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, + 0x74, 0x72, 0x69, 0x63, 0x73, 0x12, 0x75, 0x0a, 0x16, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, + 0x68, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3f, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, + 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x44, 0x69, 0x73, + 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x54, 0x6f, 0x74, 0x61, + 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x14, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x7b, 0x0a, 0x18, + 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x74, 0x68, 0x72, 0x6f, 0x74, 0x74, 0x6c, + 0x65, 0x64, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x41, + 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, + 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x54, 0x68, + 0x72, 0x6f, 0x74, 0x74, 0x6c, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x16, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x54, 0x68, 0x72, 0x6f, 0x74, + 0x74, 0x6c, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x9b, 0x01, 0x0a, 0x24, 0x64, 0x69, + 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x5f, 0x64, 0x75, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x69, 0x6c, 0x6c, 0x69, 0x73, 0x5f, 0x74, 0x6f, 0x74, + 0x61, 0x6c, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x4b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, + 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, + 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x44, 0x75, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x73, 0x54, 0x6f, 0x74, 0x61, 0x6c, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x20, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, + 0x70, 0x6f, 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x69, 0x6c, 0x6c, + 0x69, 0x73, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x95, 0x01, 0x0a, 0x22, 0x64, 0x69, 0x73, 0x70, + 0x61, 0x74, 0x63, 0x68, 0x5f, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x69, 0x6c, 0x6c, 0x69, 0x73, 0x5f, 0x6d, 0x61, 0x78, 0x18, 0x04, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x49, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, + 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, + 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x44, 0x69, 0x73, 0x70, + 0x61, 0x74, 0x63, 0x68, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x73, 0x4d, 0x61, 0x78, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x1e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x44, 0x75, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x73, 0x4d, 0x61, 0x78, 0x12, + 0x88, 0x01, 0x0a, 0x1d, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x65, 0x70, 0x6f, + 0x63, 0x68, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x45, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, + 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x44, + 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x1a, + 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x44, 0x75, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x75, 0x0a, 0x16, 0x74, 0x69, + 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x74, + 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3f, 0x2e, 0x73, 0x75, 0x70, + 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, + 0x73, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, + 0x79, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x14, 0x74, 0x69, 0x63, + 0x6b, 0x65, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x54, 0x6f, 0x74, 0x61, + 0x6c, 0x12, 0x39, 0x0a, 0x19, 0x6e, 0x6f, 0x5f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x16, 0x6e, 0x6f, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x82, 0x01, 0x0a, + 0x1b, 0x68, 0x65, 0x61, 0x6c, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x5f, 0x73, 0x75, 0x62, + 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x08, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x43, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4c, 0x45, + 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x43, 0x6c, + 0x61, 0x69, 0x6d, 0x73, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x74, + 0x61, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x18, 0x68, 0x65, 0x61, 0x6c, 0x43, 0x6c, 0x61, + 0x69, 0x6d, 0x73, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, + 0x6c, 0x12, 0x3f, 0x0a, 0x1c, 0x68, 0x65, 0x61, 0x6c, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, + 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x74, 0x6f, 0x74, 0x61, + 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x19, 0x68, 0x65, 0x61, 0x6c, 0x43, 0x6c, 0x61, + 0x69, 0x6d, 0x73, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x64, 0x54, 0x6f, 0x74, + 0x61, 0x6c, 0x12, 0x97, 0x01, 0x0a, 0x22, 0x68, 0x65, 0x61, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, + 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x5f, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, + 0x74, 0x65, 0x64, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x4a, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, + 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x56, 0x65, 0x72, 0x69, 0x66, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, + 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x1f, 0x68, 0x65, 0x61, + 0x6c, 0x56, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x53, 0x75, + 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x54, 0x0a, 0x27, + 0x68, 0x65, 0x61, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x5f, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, + 0x73, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x04, 0x52, 0x23, 0x68, + 0x65, 0x61, 0x6c, 0x56, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x41, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x54, 0x6f, 0x74, + 0x61, 0x6c, 0x12, 0x41, 0x0a, 0x1d, 0x68, 0x65, 0x61, 0x6c, 0x5f, 0x66, 0x69, 0x6e, 0x61, 0x6c, + 0x69, 0x7a, 0x65, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x73, 0x5f, 0x74, 0x6f, + 0x74, 0x61, 0x6c, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x04, 0x52, 0x1a, 0x68, 0x65, 0x61, 0x6c, 0x46, + 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x73, + 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x85, 0x01, 0x0a, 0x1c, 0x68, 0x65, 0x61, 0x6c, 0x5f, 0x66, + 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x5f, 0x63, 0x6c, 0x65, 0x61, 0x6e, 0x75, 0x70, 0x73, + 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x44, 0x2e, 0x73, + 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, + 0x69, 0x63, 0x73, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, + 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x75, 0x70, 0x73, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x52, 0x19, 0x68, 0x65, 0x61, 0x6c, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, + 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x75, 0x70, 0x73, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x3d, 0x0a, + 0x1b, 0x73, 0x65, 0x6c, 0x66, 0x5f, 0x68, 0x65, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x65, + 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x18, 0x0e, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x18, 0x73, 0x65, 0x6c, 0x66, 0x48, 0x65, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x50, + 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x12, 0x3b, 0x0a, 0x1a, + 0x73, 0x65, 0x6c, 0x66, 0x5f, 0x68, 0x65, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, + 0x67, 0x69, 0x6e, 0x67, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x17, 0x73, 0x65, 0x6c, 0x66, 0x48, 0x65, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, + 0x67, 0x69, 0x6e, 0x67, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x43, 0x0a, 0x1e, 0x72, 0x65, 0x63, + 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x63, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x73, 0x5f, + 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x1b, 0x72, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, + 0x61, 0x74, 0x65, 0x73, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x91, + 0x01, 0x0a, 0x20, 0x72, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x65, 0x76, 0x69, 0x64, 0x65, + 0x6e, 0x63, 0x65, 0x5f, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x6f, + 0x74, 0x61, 0x6c, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x48, 0x2e, 0x73, 0x75, 0x70, 0x65, + 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, + 0x2e, 0x52, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x45, 0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, 0x65, + 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x1d, 0x72, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x45, 0x76, 0x69, 0x64, + 0x65, 0x6e, 0x63, 0x65, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x74, + 0x61, 0x6c, 0x12, 0x56, 0x0a, 0x28, 0x72, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x65, 0x76, + 0x69, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x5f, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, 0x73, + 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x12, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x24, 0x72, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x45, 0x76, 0x69, + 0x64, 0x65, 0x6e, 0x63, 0x65, 0x41, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x53, 0x75, 0x62, 0x6d, + 0x69, 0x74, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x91, 0x01, 0x0a, 0x20, 0x72, + 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, + 0x13, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x48, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, + 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x2e, 0x4c, 0x45, 0x50, 0x36, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x2e, 0x52, 0x65, 0x63, + 0x68, 0x65, 0x63, 0x6b, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x61, 0x69, + 0x6c, 0x75, 0x72, 0x65, 0x73, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x1d, 0x72, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x3c, + 0x0a, 0x1a, 0x72, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, + 0x67, 0x5f, 0x63, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x73, 0x18, 0x14, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x18, 0x72, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x6e, 0x64, 0x69, + 0x6e, 0x67, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x73, 0x1a, 0x47, 0x0a, 0x19, + 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x54, + 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x49, 0x0a, 0x1b, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, + 0x68, 0x54, 0x68, 0x72, 0x6f, 0x74, 0x74, 0x6c, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x1a, 0x53, 0x0a, 0x25, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x70, 0x6f, 0x63, + 0x68, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x73, 0x54, + 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x51, 0x0a, 0x23, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, + 0x68, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x69, + 0x6c, 0x6c, 0x69, 0x73, 0x4d, 0x61, 0x78, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, + 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, + 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x4d, 0x0a, 0x1f, 0x44, 0x69, 0x73, 0x70, + 0x61, 0x74, 0x63, 0x68, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x47, 0x0a, 0x19, 0x54, 0x69, 0x63, 0x6b, 0x65, + 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x1a, 0x4b, 0x0a, 0x1d, 0x48, 0x65, 0x61, 0x6c, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x53, 0x75, + 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x52, 0x0a, + 0x24, 0x48, 0x65, 0x61, 0x6c, 0x56, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x1a, 0x4c, 0x0a, 0x1e, 0x48, 0x65, 0x61, 0x6c, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, + 0x65, 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x75, 0x70, 0x73, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, + 0x50, 0x0a, 0x22, 0x52, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x45, 0x76, 0x69, 0x64, 0x65, 0x6e, + 0x63, 0x65, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x74, 0x61, 0x6c, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x1a, 0x50, 0x0a, 0x22, 0x52, 0x65, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x45, 0x78, 0x65, 0x63, + 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x54, 0x6f, 0x74, + 0x61, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, + 0x02, 0x38, 0x01, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x4c, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x2f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x76, 0x32, 0x2f, 0x67, 0x65, + 0x6e, 0x2f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, +} var ( file_supernode_status_proto_rawDescOnce sync.Once - file_supernode_status_proto_rawDescData []byte + file_supernode_status_proto_rawDescData = file_supernode_status_proto_rawDesc ) func file_supernode_status_proto_rawDescGZIP() []byte { file_supernode_status_proto_rawDescOnce.Do(func() { - file_supernode_status_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_supernode_status_proto_rawDesc), len(file_supernode_status_proto_rawDesc))) + file_supernode_status_proto_rawDescData = protoimpl.X.CompressGZIP(file_supernode_status_proto_rawDescData) }) return file_supernode_status_proto_rawDescData } -var file_supernode_status_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_supernode_status_proto_msgTypes = make([]protoimpl.MessageInfo, 30) var file_supernode_status_proto_goTypes = []any{ (*StatusRequest)(nil), // 0: supernode.StatusRequest (*StatusResponse)(nil), // 1: supernode.StatusResponse @@ -1258,41 +1821,65 @@ var file_supernode_status_proto_goTypes = []any{ (*StatusResponse_ServiceTasks)(nil), // 3: supernode.StatusResponse.ServiceTasks (*StatusResponse_Network)(nil), // 4: supernode.StatusResponse.Network (*StatusResponse_P2PMetrics)(nil), // 5: supernode.StatusResponse.P2PMetrics - (*StatusResponse_Resources_CPU)(nil), // 6: supernode.StatusResponse.Resources.CPU - (*StatusResponse_Resources_Memory)(nil), // 7: supernode.StatusResponse.Resources.Memory - (*StatusResponse_Resources_Storage)(nil), // 8: supernode.StatusResponse.Resources.Storage - (*StatusResponse_P2PMetrics_DhtMetrics)(nil), // 9: supernode.StatusResponse.P2PMetrics.DhtMetrics - (*StatusResponse_P2PMetrics_HandleCounters)(nil), // 10: supernode.StatusResponse.P2PMetrics.HandleCounters - (*StatusResponse_P2PMetrics_BanEntry)(nil), // 11: supernode.StatusResponse.P2PMetrics.BanEntry - (*StatusResponse_P2PMetrics_DatabaseStats)(nil), // 12: supernode.StatusResponse.P2PMetrics.DatabaseStats - (*StatusResponse_P2PMetrics_DiskStatus)(nil), // 13: supernode.StatusResponse.P2PMetrics.DiskStatus - nil, // 14: supernode.StatusResponse.P2PMetrics.NetworkHandleMetricsEntry - nil, // 15: supernode.StatusResponse.P2PMetrics.ConnPoolMetricsEntry - (*StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint)(nil), // 16: supernode.StatusResponse.P2PMetrics.DhtMetrics.StoreSuccessPoint - (*StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint)(nil), // 17: supernode.StatusResponse.P2PMetrics.DhtMetrics.BatchRetrievePoint + (*StatusResponse_LEP6Metrics)(nil), // 6: supernode.StatusResponse.LEP6Metrics + (*StatusResponse_Resources_CPU)(nil), // 7: supernode.StatusResponse.Resources.CPU + (*StatusResponse_Resources_Memory)(nil), // 8: supernode.StatusResponse.Resources.Memory + (*StatusResponse_Resources_Storage)(nil), // 9: supernode.StatusResponse.Resources.Storage + (*StatusResponse_P2PMetrics_DhtMetrics)(nil), // 10: supernode.StatusResponse.P2PMetrics.DhtMetrics + (*StatusResponse_P2PMetrics_HandleCounters)(nil), // 11: supernode.StatusResponse.P2PMetrics.HandleCounters + (*StatusResponse_P2PMetrics_BanEntry)(nil), // 12: supernode.StatusResponse.P2PMetrics.BanEntry + (*StatusResponse_P2PMetrics_DatabaseStats)(nil), // 13: supernode.StatusResponse.P2PMetrics.DatabaseStats + (*StatusResponse_P2PMetrics_DiskStatus)(nil), // 14: supernode.StatusResponse.P2PMetrics.DiskStatus + nil, // 15: supernode.StatusResponse.P2PMetrics.NetworkHandleMetricsEntry + nil, // 16: supernode.StatusResponse.P2PMetrics.ConnPoolMetricsEntry + (*StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint)(nil), // 17: supernode.StatusResponse.P2PMetrics.DhtMetrics.StoreSuccessPoint + (*StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint)(nil), // 18: supernode.StatusResponse.P2PMetrics.DhtMetrics.BatchRetrievePoint + nil, // 19: supernode.StatusResponse.LEP6Metrics.DispatchResultsTotalEntry + nil, // 20: supernode.StatusResponse.LEP6Metrics.DispatchThrottledTotalEntry + nil, // 21: supernode.StatusResponse.LEP6Metrics.DispatchEpochDurationMillisTotalEntry + nil, // 22: supernode.StatusResponse.LEP6Metrics.DispatchEpochDurationMillisMaxEntry + nil, // 23: supernode.StatusResponse.LEP6Metrics.DispatchEpochDurationCountEntry + nil, // 24: supernode.StatusResponse.LEP6Metrics.TicketDiscoveryTotalEntry + nil, // 25: supernode.StatusResponse.LEP6Metrics.HealClaimsSubmittedTotalEntry + nil, // 26: supernode.StatusResponse.LEP6Metrics.HealVerificationsSubmittedTotalEntry + nil, // 27: supernode.StatusResponse.LEP6Metrics.HealFinalizeCleanupsTotalEntry + nil, // 28: supernode.StatusResponse.LEP6Metrics.RecheckEvidenceSubmittedTotalEntry + nil, // 29: supernode.StatusResponse.LEP6Metrics.RecheckExecutionFailuresTotalEntry } var file_supernode_status_proto_depIdxs = []int32{ 2, // 0: supernode.StatusResponse.resources:type_name -> supernode.StatusResponse.Resources 3, // 1: supernode.StatusResponse.running_tasks:type_name -> supernode.StatusResponse.ServiceTasks 4, // 2: supernode.StatusResponse.network:type_name -> supernode.StatusResponse.Network 5, // 3: supernode.StatusResponse.p2p_metrics:type_name -> supernode.StatusResponse.P2PMetrics - 6, // 4: supernode.StatusResponse.Resources.cpu:type_name -> supernode.StatusResponse.Resources.CPU - 7, // 5: supernode.StatusResponse.Resources.memory:type_name -> supernode.StatusResponse.Resources.Memory - 8, // 6: supernode.StatusResponse.Resources.storage_volumes:type_name -> supernode.StatusResponse.Resources.Storage - 9, // 7: supernode.StatusResponse.P2PMetrics.dht_metrics:type_name -> supernode.StatusResponse.P2PMetrics.DhtMetrics - 14, // 8: supernode.StatusResponse.P2PMetrics.network_handle_metrics:type_name -> supernode.StatusResponse.P2PMetrics.NetworkHandleMetricsEntry - 15, // 9: supernode.StatusResponse.P2PMetrics.conn_pool_metrics:type_name -> supernode.StatusResponse.P2PMetrics.ConnPoolMetricsEntry - 11, // 10: supernode.StatusResponse.P2PMetrics.ban_list:type_name -> supernode.StatusResponse.P2PMetrics.BanEntry - 12, // 11: supernode.StatusResponse.P2PMetrics.database:type_name -> supernode.StatusResponse.P2PMetrics.DatabaseStats - 13, // 12: supernode.StatusResponse.P2PMetrics.disk:type_name -> supernode.StatusResponse.P2PMetrics.DiskStatus - 16, // 13: supernode.StatusResponse.P2PMetrics.DhtMetrics.store_success_recent:type_name -> supernode.StatusResponse.P2PMetrics.DhtMetrics.StoreSuccessPoint - 17, // 14: supernode.StatusResponse.P2PMetrics.DhtMetrics.batch_retrieve_recent:type_name -> supernode.StatusResponse.P2PMetrics.DhtMetrics.BatchRetrievePoint - 10, // 15: supernode.StatusResponse.P2PMetrics.NetworkHandleMetricsEntry.value:type_name -> supernode.StatusResponse.P2PMetrics.HandleCounters - 16, // [16:16] is the sub-list for method output_type - 16, // [16:16] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 6, // 4: supernode.StatusResponse.lep6_metrics:type_name -> supernode.StatusResponse.LEP6Metrics + 7, // 5: supernode.StatusResponse.Resources.cpu:type_name -> supernode.StatusResponse.Resources.CPU + 8, // 6: supernode.StatusResponse.Resources.memory:type_name -> supernode.StatusResponse.Resources.Memory + 9, // 7: supernode.StatusResponse.Resources.storage_volumes:type_name -> supernode.StatusResponse.Resources.Storage + 10, // 8: supernode.StatusResponse.P2PMetrics.dht_metrics:type_name -> supernode.StatusResponse.P2PMetrics.DhtMetrics + 15, // 9: supernode.StatusResponse.P2PMetrics.network_handle_metrics:type_name -> supernode.StatusResponse.P2PMetrics.NetworkHandleMetricsEntry + 16, // 10: supernode.StatusResponse.P2PMetrics.conn_pool_metrics:type_name -> supernode.StatusResponse.P2PMetrics.ConnPoolMetricsEntry + 12, // 11: supernode.StatusResponse.P2PMetrics.ban_list:type_name -> supernode.StatusResponse.P2PMetrics.BanEntry + 13, // 12: supernode.StatusResponse.P2PMetrics.database:type_name -> supernode.StatusResponse.P2PMetrics.DatabaseStats + 14, // 13: supernode.StatusResponse.P2PMetrics.disk:type_name -> supernode.StatusResponse.P2PMetrics.DiskStatus + 19, // 14: supernode.StatusResponse.LEP6Metrics.dispatch_results_total:type_name -> supernode.StatusResponse.LEP6Metrics.DispatchResultsTotalEntry + 20, // 15: supernode.StatusResponse.LEP6Metrics.dispatch_throttled_total:type_name -> supernode.StatusResponse.LEP6Metrics.DispatchThrottledTotalEntry + 21, // 16: supernode.StatusResponse.LEP6Metrics.dispatch_epoch_duration_millis_total:type_name -> supernode.StatusResponse.LEP6Metrics.DispatchEpochDurationMillisTotalEntry + 22, // 17: supernode.StatusResponse.LEP6Metrics.dispatch_epoch_duration_millis_max:type_name -> supernode.StatusResponse.LEP6Metrics.DispatchEpochDurationMillisMaxEntry + 23, // 18: supernode.StatusResponse.LEP6Metrics.dispatch_epoch_duration_count:type_name -> supernode.StatusResponse.LEP6Metrics.DispatchEpochDurationCountEntry + 24, // 19: supernode.StatusResponse.LEP6Metrics.ticket_discovery_total:type_name -> supernode.StatusResponse.LEP6Metrics.TicketDiscoveryTotalEntry + 25, // 20: supernode.StatusResponse.LEP6Metrics.heal_claims_submitted_total:type_name -> supernode.StatusResponse.LEP6Metrics.HealClaimsSubmittedTotalEntry + 26, // 21: supernode.StatusResponse.LEP6Metrics.heal_verifications_submitted_total:type_name -> supernode.StatusResponse.LEP6Metrics.HealVerificationsSubmittedTotalEntry + 27, // 22: supernode.StatusResponse.LEP6Metrics.heal_finalize_cleanups_total:type_name -> supernode.StatusResponse.LEP6Metrics.HealFinalizeCleanupsTotalEntry + 28, // 23: supernode.StatusResponse.LEP6Metrics.recheck_evidence_submitted_total:type_name -> supernode.StatusResponse.LEP6Metrics.RecheckEvidenceSubmittedTotalEntry + 29, // 24: supernode.StatusResponse.LEP6Metrics.recheck_execution_failures_total:type_name -> supernode.StatusResponse.LEP6Metrics.RecheckExecutionFailuresTotalEntry + 17, // 25: supernode.StatusResponse.P2PMetrics.DhtMetrics.store_success_recent:type_name -> supernode.StatusResponse.P2PMetrics.DhtMetrics.StoreSuccessPoint + 18, // 26: supernode.StatusResponse.P2PMetrics.DhtMetrics.batch_retrieve_recent:type_name -> supernode.StatusResponse.P2PMetrics.DhtMetrics.BatchRetrievePoint + 11, // 27: supernode.StatusResponse.P2PMetrics.NetworkHandleMetricsEntry.value:type_name -> supernode.StatusResponse.P2PMetrics.HandleCounters + 28, // [28:28] is the sub-list for method output_type + 28, // [28:28] is the sub-list for method input_type + 28, // [28:28] is the sub-list for extension type_name + 28, // [28:28] is the sub-list for extension extendee + 0, // [0:28] is the sub-list for field type_name } func init() { file_supernode_status_proto_init() } @@ -1300,13 +1887,219 @@ func file_supernode_status_proto_init() { if File_supernode_status_proto != nil { return } + if !protoimpl.UnsafeEnabled { + file_supernode_status_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*StatusRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[1].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[2].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_Resources); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[3].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_ServiceTasks); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[4].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_Network); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[5].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_P2PMetrics); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[6].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_LEP6Metrics); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[7].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_Resources_CPU); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[8].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_Resources_Memory); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[9].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_Resources_Storage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[10].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_P2PMetrics_DhtMetrics); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[11].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_P2PMetrics_HandleCounters); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[12].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_P2PMetrics_BanEntry); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[13].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_P2PMetrics_DatabaseStats); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[14].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_P2PMetrics_DiskStatus); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[17].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_P2PMetrics_DhtMetrics_StoreSuccessPoint); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_status_proto_msgTypes[18].Exporter = func(v any, i int) any { + switch v := v.(*StatusResponse_P2PMetrics_DhtMetrics_BatchRetrievePoint); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_supernode_status_proto_rawDesc), len(file_supernode_status_proto_rawDesc)), + RawDescriptor: file_supernode_status_proto_rawDesc, NumEnums: 0, - NumMessages: 18, + NumMessages: 30, NumExtensions: 0, NumServices: 0, }, @@ -1315,6 +2108,7 @@ func file_supernode_status_proto_init() { MessageInfos: file_supernode_status_proto_msgTypes, }.Build() File_supernode_status_proto = out.File + file_supernode_status_proto_rawDesc = nil file_supernode_status_proto_goTypes = nil file_supernode_status_proto_depIdxs = nil } diff --git a/gen/supernode/storage_challenge.pb.go b/gen/supernode/storage_challenge.pb.go index e62e8be7..c8591176 100644 --- a/gen/supernode/storage_challenge.pb.go +++ b/gen/supernode/storage_challenge.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 -// protoc v3.21.12 +// protoc-gen-go v1.34.2 +// protoc v4.25.1 // source: supernode/storage_challenge.proto package supernode @@ -11,7 +11,6 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" - unsafe "unsafe" ) const ( @@ -21,26 +20,371 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ByteRange represents a half-open byte range [start, end) into an artifact. +type ByteRange struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Start uint64 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"` + End uint64 `protobuf:"varint,2,opt,name=end,proto3" json:"end,omitempty"` // exclusive +} + +func (x *ByteRange) Reset() { + *x = ByteRange{} + if protoimpl.UnsafeEnabled { + mi := &file_supernode_storage_challenge_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ByteRange) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ByteRange) ProtoMessage() {} + +func (x *ByteRange) ProtoReflect() protoreflect.Message { + mi := &file_supernode_storage_challenge_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ByteRange.ProtoReflect.Descriptor instead. +func (*ByteRange) Descriptor() ([]byte, []int) { + return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{0} +} + +func (x *ByteRange) GetStart() uint64 { + if x != nil { + return x.Start + } + return 0 +} + +func (x *ByteRange) GetEnd() uint64 { + if x != nil { + return x.End + } + return 0 +} + +type GetCompoundProofRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` + EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` + Seed []byte `protobuf:"bytes,3,opt,name=seed,proto3" json:"seed,omitempty"` + TicketId string `protobuf:"bytes,4,opt,name=ticket_id,json=ticketId,proto3" json:"ticket_id,omitempty"` + TargetSupernodeAccount string `protobuf:"bytes,5,opt,name=target_supernode_account,json=targetSupernodeAccount,proto3" json:"target_supernode_account,omitempty"` + ChallengerAccount string `protobuf:"bytes,6,opt,name=challenger_account,json=challengerAccount,proto3" json:"challenger_account,omitempty"` + ObserverAccounts []string `protobuf:"bytes,7,rep,name=observer_accounts,json=observerAccounts,proto3" json:"observer_accounts,omitempty"` + ArtifactClass uint32 `protobuf:"varint,8,opt,name=artifact_class,json=artifactClass,proto3" json:"artifact_class,omitempty"` // mirrors audittypes.StorageProofArtifactClass + ArtifactOrdinal uint32 `protobuf:"varint,9,opt,name=artifact_ordinal,json=artifactOrdinal,proto3" json:"artifact_ordinal,omitempty"` + ArtifactCount uint32 `protobuf:"varint,10,opt,name=artifact_count,json=artifactCount,proto3" json:"artifact_count,omitempty"` + BucketType uint32 `protobuf:"varint,11,opt,name=bucket_type,json=bucketType,proto3" json:"bucket_type,omitempty"` // mirrors audittypes.StorageProofBucketType + ArtifactKey string `protobuf:"bytes,12,opt,name=artifact_key,json=artifactKey,proto3" json:"artifact_key,omitempty"` + ArtifactSize uint64 `protobuf:"varint,13,opt,name=artifact_size,json=artifactSize,proto3" json:"artifact_size,omitempty"` + Ranges []*ByteRange `protobuf:"bytes,14,rep,name=ranges,proto3" json:"ranges,omitempty"` // exactly LEP6CompoundRangesPerArtifact (=4); each size LEP6CompoundRangeLenBytes (=256) +} + +func (x *GetCompoundProofRequest) Reset() { + *x = GetCompoundProofRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_supernode_storage_challenge_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetCompoundProofRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCompoundProofRequest) ProtoMessage() {} + +func (x *GetCompoundProofRequest) ProtoReflect() protoreflect.Message { + mi := &file_supernode_storage_challenge_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCompoundProofRequest.ProtoReflect.Descriptor instead. +func (*GetCompoundProofRequest) Descriptor() ([]byte, []int) { + return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{1} +} + +func (x *GetCompoundProofRequest) GetChallengeId() string { + if x != nil { + return x.ChallengeId + } + return "" +} + +func (x *GetCompoundProofRequest) GetEpochId() uint64 { + if x != nil { + return x.EpochId + } + return 0 +} + +func (x *GetCompoundProofRequest) GetSeed() []byte { + if x != nil { + return x.Seed + } + return nil +} + +func (x *GetCompoundProofRequest) GetTicketId() string { + if x != nil { + return x.TicketId + } + return "" +} + +func (x *GetCompoundProofRequest) GetTargetSupernodeAccount() string { + if x != nil { + return x.TargetSupernodeAccount + } + return "" +} + +func (x *GetCompoundProofRequest) GetChallengerAccount() string { + if x != nil { + return x.ChallengerAccount + } + return "" +} + +func (x *GetCompoundProofRequest) GetObserverAccounts() []string { + if x != nil { + return x.ObserverAccounts + } + return nil +} + +func (x *GetCompoundProofRequest) GetArtifactClass() uint32 { + if x != nil { + return x.ArtifactClass + } + return 0 +} + +func (x *GetCompoundProofRequest) GetArtifactOrdinal() uint32 { + if x != nil { + return x.ArtifactOrdinal + } + return 0 +} + +func (x *GetCompoundProofRequest) GetArtifactCount() uint32 { + if x != nil { + return x.ArtifactCount + } + return 0 +} + +func (x *GetCompoundProofRequest) GetBucketType() uint32 { + if x != nil { + return x.BucketType + } + return 0 +} + +func (x *GetCompoundProofRequest) GetArtifactKey() string { + if x != nil { + return x.ArtifactKey + } + return "" +} + +func (x *GetCompoundProofRequest) GetArtifactSize() uint64 { + if x != nil { + return x.ArtifactSize + } + return 0 +} + +func (x *GetCompoundProofRequest) GetRanges() []*ByteRange { + if x != nil { + return x.Ranges + } + return nil +} + +type GetCompoundProofResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` + EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` + TicketId string `protobuf:"bytes,3,opt,name=ticket_id,json=ticketId,proto3" json:"ticket_id,omitempty"` + ArtifactClass uint32 `protobuf:"varint,4,opt,name=artifact_class,json=artifactClass,proto3" json:"artifact_class,omitempty"` + ArtifactOrdinal uint32 `protobuf:"varint,5,opt,name=artifact_ordinal,json=artifactOrdinal,proto3" json:"artifact_ordinal,omitempty"` + BucketType uint32 `protobuf:"varint,6,opt,name=bucket_type,json=bucketType,proto3" json:"bucket_type,omitempty"` + ArtifactKey string `protobuf:"bytes,7,opt,name=artifact_key,json=artifactKey,proto3" json:"artifact_key,omitempty"` + RangeBytes [][]byte `protobuf:"bytes,8,rep,name=range_bytes,json=rangeBytes,proto3" json:"range_bytes,omitempty"` // i-th matches i-th request range + ProofHashHex string `protobuf:"bytes,9,opt,name=proof_hash_hex,json=proofHashHex,proto3" json:"proof_hash_hex,omitempty"` // BLAKE3(concat(range_bytes...)) lowercase hex + RecipientSignature string `protobuf:"bytes,10,opt,name=recipient_signature,json=recipientSignature,proto3" json:"recipient_signature,omitempty"` // recipient's keyring signature + Ok bool `protobuf:"varint,11,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,12,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *GetCompoundProofResponse) Reset() { + *x = GetCompoundProofResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_supernode_storage_challenge_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetCompoundProofResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCompoundProofResponse) ProtoMessage() {} + +func (x *GetCompoundProofResponse) ProtoReflect() protoreflect.Message { + mi := &file_supernode_storage_challenge_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCompoundProofResponse.ProtoReflect.Descriptor instead. +func (*GetCompoundProofResponse) Descriptor() ([]byte, []int) { + return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{2} +} + +func (x *GetCompoundProofResponse) GetChallengeId() string { + if x != nil { + return x.ChallengeId + } + return "" +} + +func (x *GetCompoundProofResponse) GetEpochId() uint64 { + if x != nil { + return x.EpochId + } + return 0 +} + +func (x *GetCompoundProofResponse) GetTicketId() string { + if x != nil { + return x.TicketId + } + return "" +} + +func (x *GetCompoundProofResponse) GetArtifactClass() uint32 { + if x != nil { + return x.ArtifactClass + } + return 0 +} + +func (x *GetCompoundProofResponse) GetArtifactOrdinal() uint32 { + if x != nil { + return x.ArtifactOrdinal + } + return 0 +} + +func (x *GetCompoundProofResponse) GetBucketType() uint32 { + if x != nil { + return x.BucketType + } + return 0 +} + +func (x *GetCompoundProofResponse) GetArtifactKey() string { + if x != nil { + return x.ArtifactKey + } + return "" +} + +func (x *GetCompoundProofResponse) GetRangeBytes() [][]byte { + if x != nil { + return x.RangeBytes + } + return nil +} + +func (x *GetCompoundProofResponse) GetProofHashHex() string { + if x != nil { + return x.ProofHashHex + } + return "" +} + +func (x *GetCompoundProofResponse) GetRecipientSignature() string { + if x != nil { + return x.RecipientSignature + } + return "" +} + +func (x *GetCompoundProofResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *GetCompoundProofResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + type GetSliceProofRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` - EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` - Seed []byte `protobuf:"bytes,3,opt,name=seed,proto3" json:"seed,omitempty"` - FileKey string `protobuf:"bytes,4,opt,name=file_key,json=fileKey,proto3" json:"file_key,omitempty"` - RequestedStart uint64 `protobuf:"varint,5,opt,name=requested_start,json=requestedStart,proto3" json:"requested_start,omitempty"` - RequestedEnd uint64 `protobuf:"varint,6,opt,name=requested_end,json=requestedEnd,proto3" json:"requested_end,omitempty"` - ChallengerId string `protobuf:"bytes,7,opt,name=challenger_id,json=challengerId,proto3" json:"challenger_id,omitempty"` - RecipientId string `protobuf:"bytes,8,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` - ObserverIds []string `protobuf:"bytes,9,rep,name=observer_ids,json=observerIds,proto3" json:"observer_ids,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` + EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` + Seed []byte `protobuf:"bytes,3,opt,name=seed,proto3" json:"seed,omitempty"` + FileKey string `protobuf:"bytes,4,opt,name=file_key,json=fileKey,proto3" json:"file_key,omitempty"` + RequestedStart uint64 `protobuf:"varint,5,opt,name=requested_start,json=requestedStart,proto3" json:"requested_start,omitempty"` + RequestedEnd uint64 `protobuf:"varint,6,opt,name=requested_end,json=requestedEnd,proto3" json:"requested_end,omitempty"` + ChallengerId string `protobuf:"bytes,7,opt,name=challenger_id,json=challengerId,proto3" json:"challenger_id,omitempty"` + RecipientId string `protobuf:"bytes,8,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` + ObserverIds []string `protobuf:"bytes,9,rep,name=observer_ids,json=observerIds,proto3" json:"observer_ids,omitempty"` } func (x *GetSliceProofRequest) Reset() { *x = GetSliceProofRequest{} - mi := &file_supernode_storage_challenge_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_storage_challenge_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *GetSliceProofRequest) String() string { @@ -50,8 +394,8 @@ func (x *GetSliceProofRequest) String() string { func (*GetSliceProofRequest) ProtoMessage() {} func (x *GetSliceProofRequest) ProtoReflect() protoreflect.Message { - mi := &file_supernode_storage_challenge_proto_msgTypes[0] - if x != nil { + mi := &file_supernode_storage_challenge_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -63,7 +407,7 @@ func (x *GetSliceProofRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSliceProofRequest.ProtoReflect.Descriptor instead. func (*GetSliceProofRequest) Descriptor() ([]byte, []int) { - return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{0} + return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{3} } func (x *GetSliceProofRequest) GetChallengeId() string { @@ -130,26 +474,29 @@ func (x *GetSliceProofRequest) GetObserverIds() []string { } type GetSliceProofResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` - EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` - FileKey string `protobuf:"bytes,3,opt,name=file_key,json=fileKey,proto3" json:"file_key,omitempty"` - Start uint64 `protobuf:"varint,4,opt,name=start,proto3" json:"start,omitempty"` - End uint64 `protobuf:"varint,5,opt,name=end,proto3" json:"end,omitempty"` - RecipientId string `protobuf:"bytes,6,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` - Slice []byte `protobuf:"bytes,7,opt,name=slice,proto3" json:"slice,omitempty"` - ProofHashHex string `protobuf:"bytes,8,opt,name=proof_hash_hex,json=proofHashHex,proto3" json:"proof_hash_hex,omitempty"` - Ok bool `protobuf:"varint,9,opt,name=ok,proto3" json:"ok,omitempty"` - Error string `protobuf:"bytes,10,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` + EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` + FileKey string `protobuf:"bytes,3,opt,name=file_key,json=fileKey,proto3" json:"file_key,omitempty"` + Start uint64 `protobuf:"varint,4,opt,name=start,proto3" json:"start,omitempty"` + End uint64 `protobuf:"varint,5,opt,name=end,proto3" json:"end,omitempty"` + RecipientId string `protobuf:"bytes,6,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` + Slice []byte `protobuf:"bytes,7,opt,name=slice,proto3" json:"slice,omitempty"` + ProofHashHex string `protobuf:"bytes,8,opt,name=proof_hash_hex,json=proofHashHex,proto3" json:"proof_hash_hex,omitempty"` + Ok bool `protobuf:"varint,9,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,10,opt,name=error,proto3" json:"error,omitempty"` } func (x *GetSliceProofResponse) Reset() { *x = GetSliceProofResponse{} - mi := &file_supernode_storage_challenge_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_storage_challenge_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *GetSliceProofResponse) String() string { @@ -159,8 +506,8 @@ func (x *GetSliceProofResponse) String() string { func (*GetSliceProofResponse) ProtoMessage() {} func (x *GetSliceProofResponse) ProtoReflect() protoreflect.Message { - mi := &file_supernode_storage_challenge_proto_msgTypes[1] - if x != nil { + mi := &file_supernode_storage_challenge_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -172,7 +519,7 @@ func (x *GetSliceProofResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSliceProofResponse.ProtoReflect.Descriptor instead. func (*GetSliceProofResponse) Descriptor() ([]byte, []int) { - return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{1} + return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{4} } func (x *GetSliceProofResponse) GetChallengeId() string { @@ -246,25 +593,28 @@ func (x *GetSliceProofResponse) GetError() string { } type VerifySliceProofRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` - EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` - FileKey string `protobuf:"bytes,3,opt,name=file_key,json=fileKey,proto3" json:"file_key,omitempty"` - Start uint64 `protobuf:"varint,4,opt,name=start,proto3" json:"start,omitempty"` - End uint64 `protobuf:"varint,5,opt,name=end,proto3" json:"end,omitempty"` - Slice []byte `protobuf:"bytes,6,opt,name=slice,proto3" json:"slice,omitempty"` - ProofHashHex string `protobuf:"bytes,7,opt,name=proof_hash_hex,json=proofHashHex,proto3" json:"proof_hash_hex,omitempty"` - ChallengerId string `protobuf:"bytes,8,opt,name=challenger_id,json=challengerId,proto3" json:"challenger_id,omitempty"` - RecipientId string `protobuf:"bytes,9,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` + EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` + FileKey string `protobuf:"bytes,3,opt,name=file_key,json=fileKey,proto3" json:"file_key,omitempty"` + Start uint64 `protobuf:"varint,4,opt,name=start,proto3" json:"start,omitempty"` + End uint64 `protobuf:"varint,5,opt,name=end,proto3" json:"end,omitempty"` + Slice []byte `protobuf:"bytes,6,opt,name=slice,proto3" json:"slice,omitempty"` + ProofHashHex string `protobuf:"bytes,7,opt,name=proof_hash_hex,json=proofHashHex,proto3" json:"proof_hash_hex,omitempty"` + ChallengerId string `protobuf:"bytes,8,opt,name=challenger_id,json=challengerId,proto3" json:"challenger_id,omitempty"` + RecipientId string `protobuf:"bytes,9,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` } func (x *VerifySliceProofRequest) Reset() { *x = VerifySliceProofRequest{} - mi := &file_supernode_storage_challenge_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_storage_challenge_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *VerifySliceProofRequest) String() string { @@ -274,8 +624,8 @@ func (x *VerifySliceProofRequest) String() string { func (*VerifySliceProofRequest) ProtoMessage() {} func (x *VerifySliceProofRequest) ProtoReflect() protoreflect.Message { - mi := &file_supernode_storage_challenge_proto_msgTypes[2] - if x != nil { + mi := &file_supernode_storage_challenge_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -287,7 +637,7 @@ func (x *VerifySliceProofRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifySliceProofRequest.ProtoReflect.Descriptor instead. func (*VerifySliceProofRequest) Descriptor() ([]byte, []int) { - return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{2} + return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{5} } func (x *VerifySliceProofRequest) GetChallengeId() string { @@ -354,21 +704,24 @@ func (x *VerifySliceProofRequest) GetRecipientId() string { } type VerifySliceProofResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` - EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` - ObserverId string `protobuf:"bytes,3,opt,name=observer_id,json=observerId,proto3" json:"observer_id,omitempty"` - Ok bool `protobuf:"varint,4,opt,name=ok,proto3" json:"ok,omitempty"` - Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields + state protoimpl.MessageState sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ChallengeId string `protobuf:"bytes,1,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"` + EpochId uint64 `protobuf:"varint,2,opt,name=epoch_id,json=epochId,proto3" json:"epoch_id,omitempty"` + ObserverId string `protobuf:"bytes,3,opt,name=observer_id,json=observerId,proto3" json:"observer_id,omitempty"` + Ok bool `protobuf:"varint,4,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` } func (x *VerifySliceProofResponse) Reset() { *x = VerifySliceProofResponse{} - mi := &file_supernode_storage_challenge_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if protoimpl.UnsafeEnabled { + mi := &file_supernode_storage_challenge_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } } func (x *VerifySliceProofResponse) String() string { @@ -378,8 +731,8 @@ func (x *VerifySliceProofResponse) String() string { func (*VerifySliceProofResponse) ProtoMessage() {} func (x *VerifySliceProofResponse) ProtoReflect() protoreflect.Message { - mi := &file_supernode_storage_challenge_proto_msgTypes[3] - if x != nil { + mi := &file_supernode_storage_challenge_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -391,7 +744,7 @@ func (x *VerifySliceProofResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifySliceProofResponse.ProtoReflect.Descriptor instead. func (*VerifySliceProofResponse) Descriptor() ([]byte, []int) { - return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{3} + return file_supernode_storage_challenge_proto_rawDescGZIP(), []int{6} } func (x *VerifySliceProofResponse) GetChallengeId() string { @@ -431,81 +784,202 @@ func (x *VerifySliceProofResponse) GetError() string { var File_supernode_storage_challenge_proto protoreflect.FileDescriptor -const file_supernode_storage_challenge_proto_rawDesc = "" + - "\n" + - "!supernode/storage_challenge.proto\x12\tsupernode\"\xbc\x02\n" + - "\x14GetSliceProofRequest\x12!\n" + - "\fchallenge_id\x18\x01 \x01(\tR\vchallengeId\x12\x19\n" + - "\bepoch_id\x18\x02 \x01(\x04R\aepochId\x12\x12\n" + - "\x04seed\x18\x03 \x01(\fR\x04seed\x12\x19\n" + - "\bfile_key\x18\x04 \x01(\tR\afileKey\x12'\n" + - "\x0frequested_start\x18\x05 \x01(\x04R\x0erequestedStart\x12#\n" + - "\rrequested_end\x18\x06 \x01(\x04R\frequestedEnd\x12#\n" + - "\rchallenger_id\x18\a \x01(\tR\fchallengerId\x12!\n" + - "\frecipient_id\x18\b \x01(\tR\vrecipientId\x12!\n" + - "\fobserver_ids\x18\t \x03(\tR\vobserverIds\"\x9d\x02\n" + - "\x15GetSliceProofResponse\x12!\n" + - "\fchallenge_id\x18\x01 \x01(\tR\vchallengeId\x12\x19\n" + - "\bepoch_id\x18\x02 \x01(\x04R\aepochId\x12\x19\n" + - "\bfile_key\x18\x03 \x01(\tR\afileKey\x12\x14\n" + - "\x05start\x18\x04 \x01(\x04R\x05start\x12\x10\n" + - "\x03end\x18\x05 \x01(\x04R\x03end\x12!\n" + - "\frecipient_id\x18\x06 \x01(\tR\vrecipientId\x12\x14\n" + - "\x05slice\x18\a \x01(\fR\x05slice\x12$\n" + - "\x0eproof_hash_hex\x18\b \x01(\tR\fproofHashHex\x12\x0e\n" + - "\x02ok\x18\t \x01(\bR\x02ok\x12\x14\n" + - "\x05error\x18\n" + - " \x01(\tR\x05error\"\x9e\x02\n" + - "\x17VerifySliceProofRequest\x12!\n" + - "\fchallenge_id\x18\x01 \x01(\tR\vchallengeId\x12\x19\n" + - "\bepoch_id\x18\x02 \x01(\x04R\aepochId\x12\x19\n" + - "\bfile_key\x18\x03 \x01(\tR\afileKey\x12\x14\n" + - "\x05start\x18\x04 \x01(\x04R\x05start\x12\x10\n" + - "\x03end\x18\x05 \x01(\x04R\x03end\x12\x14\n" + - "\x05slice\x18\x06 \x01(\fR\x05slice\x12$\n" + - "\x0eproof_hash_hex\x18\a \x01(\tR\fproofHashHex\x12#\n" + - "\rchallenger_id\x18\b \x01(\tR\fchallengerId\x12!\n" + - "\frecipient_id\x18\t \x01(\tR\vrecipientId\"\x9f\x01\n" + - "\x18VerifySliceProofResponse\x12!\n" + - "\fchallenge_id\x18\x01 \x01(\tR\vchallengeId\x12\x19\n" + - "\bepoch_id\x18\x02 \x01(\x04R\aepochId\x12\x1f\n" + - "\vobserver_id\x18\x03 \x01(\tR\n" + - "observerId\x12\x0e\n" + - "\x02ok\x18\x04 \x01(\bR\x02ok\x12\x14\n" + - "\x05error\x18\x05 \x01(\tR\x05error2\xce\x01\n" + - "\x17StorageChallengeService\x12T\n" + - "\rGetSliceProof\x12\x1f.supernode.GetSliceProofRequest\x1a .supernode.GetSliceProofResponse\"\x00\x12]\n" + - "\x10VerifySliceProof\x12\".supernode.VerifySliceProofRequest\x1a#.supernode.VerifySliceProofResponse\"\x00B6Z4github.com/LumeraProtocol/supernode/v2/gen/supernodeb\x06proto3" +var file_supernode_storage_challenge_proto_rawDesc = []byte{ + 0x0a, 0x21, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x73, 0x74, 0x6f, 0x72, + 0x61, 0x67, 0x65, 0x5f, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x22, 0x33, + 0x0a, 0x09, 0x42, 0x79, 0x74, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, + 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, + 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, + 0x65, 0x6e, 0x64, 0x22, 0xae, 0x04, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6f, + 0x75, 0x6e, 0x64, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x21, 0x0a, 0x0c, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, + 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x49, 0x64, 0x12, 0x12, 0x0a, + 0x04, 0x73, 0x65, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x73, 0x65, 0x65, + 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x38, + 0x0a, 0x18, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, + 0x64, 0x65, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x16, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x53, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, + 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x68, 0x61, 0x6c, + 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x72, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x72, + 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2b, 0x0a, 0x11, 0x6f, 0x62, 0x73, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x18, 0x07, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x10, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, + 0x5f, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x61, 0x72, + 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x61, + 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0f, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x4f, + 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, + 0x63, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, + 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1f, 0x0a, + 0x0b, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0b, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x0a, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x21, + 0x0a, 0x0c, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x0c, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x4b, 0x65, + 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f, 0x73, 0x69, + 0x7a, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, + 0x63, 0x74, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, + 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, + 0x64, 0x65, 0x2e, 0x42, 0x79, 0x74, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x72, 0x61, + 0x6e, 0x67, 0x65, 0x73, 0x22, 0xa9, 0x03, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x70, + 0x6f, 0x75, 0x6e, 0x64, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, + 0x67, 0x65, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x49, 0x64, 0x12, + 0x1b, 0x0a, 0x09, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, + 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x43, 0x6c, + 0x61, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f, + 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0f, 0x61, + 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x4f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, 0x12, 0x1f, + 0x0a, 0x0b, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, + 0x21, 0x0a, 0x0c, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x4b, + 0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x62, 0x79, 0x74, 0x65, + 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0a, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x42, 0x79, + 0x74, 0x65, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x5f, 0x68, 0x61, 0x73, + 0x68, 0x5f, 0x68, 0x65, 0x78, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x6f, + 0x6f, 0x66, 0x48, 0x61, 0x73, 0x68, 0x48, 0x65, 0x78, 0x12, 0x2f, 0x0a, 0x13, 0x72, 0x65, 0x63, + 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, + 0x74, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, + 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x22, 0xbc, 0x02, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x53, 0x6c, 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f, + 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x68, 0x61, + 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, + 0x65, 0x70, 0x6f, 0x63, 0x68, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, + 0x65, 0x70, 0x6f, 0x63, 0x68, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x65, 0x65, 0x64, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x73, 0x65, 0x65, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x66, + 0x69, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, + 0x69, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x27, 0x0a, 0x0f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x65, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x0e, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x65, 0x64, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, + 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x65, 0x6e, 0x64, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x65, + 0x64, 0x45, 0x6e, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x68, 0x61, + 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x63, + 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, + 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x0b, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x73, 0x22, + 0x9d, 0x02, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x53, 0x6c, 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x6f, + 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x68, 0x61, + 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, + 0x65, 0x70, 0x6f, 0x63, 0x68, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, + 0x65, 0x70, 0x6f, 0x63, 0x68, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x5f, + 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x69, 0x6c, 0x65, 0x4b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, + 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, + 0x05, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x6c, + 0x69, 0x63, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x5f, 0x68, 0x61, 0x73, + 0x68, 0x5f, 0x68, 0x65, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x6f, + 0x6f, 0x66, 0x48, 0x61, 0x73, 0x68, 0x48, 0x65, 0x78, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, + 0x9e, 0x02, 0x0a, 0x17, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x53, 0x6c, 0x69, 0x63, 0x65, 0x50, + 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x63, + 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x49, 0x64, 0x12, 0x19, + 0x0a, 0x08, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x07, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x69, 0x6c, + 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x69, 0x6c, + 0x65, 0x4b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, + 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, 0x14, 0x0a, 0x05, + 0x73, 0x6c, 0x69, 0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x6c, 0x69, + 0x63, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x5f, 0x68, 0x61, 0x73, 0x68, + 0x5f, 0x68, 0x65, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x6f, 0x6f, + 0x66, 0x48, 0x61, 0x73, 0x68, 0x48, 0x65, 0x78, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x68, 0x61, 0x6c, + 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, + 0x0c, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, + 0x22, 0x9f, 0x01, 0x0a, 0x18, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x53, 0x6c, 0x69, 0x63, 0x65, + 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, + 0x0c, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x49, 0x64, + 0x12, 0x19, 0x0a, 0x08, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x04, 0x52, 0x07, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, + 0x62, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0a, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x0e, 0x0a, 0x02, + 0x6f, 0x6b, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x14, 0x0a, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x32, 0xad, 0x02, 0x0a, 0x17, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x43, 0x68, + 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x54, + 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x53, 0x6c, 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x12, + 0x1f, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x53, + 0x6c, 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x20, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x47, 0x65, 0x74, + 0x53, 0x6c, 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x12, 0x5d, 0x0a, 0x10, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x53, 0x6c, + 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x12, 0x22, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, + 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x53, 0x6c, 0x69, 0x63, 0x65, + 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x73, + 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x53, + 0x6c, 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x12, 0x5d, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x75, + 0x6e, 0x64, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x12, 0x22, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, + 0x6f, 0x64, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x75, 0x6e, 0x64, 0x50, + 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x73, 0x75, + 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6f, + 0x75, 0x6e, 0x64, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x4c, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x2f, + 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x76, 0x32, 0x2f, 0x67, 0x65, 0x6e, + 0x2f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} var ( file_supernode_storage_challenge_proto_rawDescOnce sync.Once - file_supernode_storage_challenge_proto_rawDescData []byte + file_supernode_storage_challenge_proto_rawDescData = file_supernode_storage_challenge_proto_rawDesc ) func file_supernode_storage_challenge_proto_rawDescGZIP() []byte { file_supernode_storage_challenge_proto_rawDescOnce.Do(func() { - file_supernode_storage_challenge_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_supernode_storage_challenge_proto_rawDesc), len(file_supernode_storage_challenge_proto_rawDesc))) + file_supernode_storage_challenge_proto_rawDescData = protoimpl.X.CompressGZIP(file_supernode_storage_challenge_proto_rawDescData) }) return file_supernode_storage_challenge_proto_rawDescData } -var file_supernode_storage_challenge_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_supernode_storage_challenge_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_supernode_storage_challenge_proto_goTypes = []any{ - (*GetSliceProofRequest)(nil), // 0: supernode.GetSliceProofRequest - (*GetSliceProofResponse)(nil), // 1: supernode.GetSliceProofResponse - (*VerifySliceProofRequest)(nil), // 2: supernode.VerifySliceProofRequest - (*VerifySliceProofResponse)(nil), // 3: supernode.VerifySliceProofResponse + (*ByteRange)(nil), // 0: supernode.ByteRange + (*GetCompoundProofRequest)(nil), // 1: supernode.GetCompoundProofRequest + (*GetCompoundProofResponse)(nil), // 2: supernode.GetCompoundProofResponse + (*GetSliceProofRequest)(nil), // 3: supernode.GetSliceProofRequest + (*GetSliceProofResponse)(nil), // 4: supernode.GetSliceProofResponse + (*VerifySliceProofRequest)(nil), // 5: supernode.VerifySliceProofRequest + (*VerifySliceProofResponse)(nil), // 6: supernode.VerifySliceProofResponse } var file_supernode_storage_challenge_proto_depIdxs = []int32{ - 0, // 0: supernode.StorageChallengeService.GetSliceProof:input_type -> supernode.GetSliceProofRequest - 2, // 1: supernode.StorageChallengeService.VerifySliceProof:input_type -> supernode.VerifySliceProofRequest - 1, // 2: supernode.StorageChallengeService.GetSliceProof:output_type -> supernode.GetSliceProofResponse - 3, // 3: supernode.StorageChallengeService.VerifySliceProof:output_type -> supernode.VerifySliceProofResponse - 2, // [2:4] is the sub-list for method output_type - 0, // [0:2] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 0, // 0: supernode.GetCompoundProofRequest.ranges:type_name -> supernode.ByteRange + 3, // 1: supernode.StorageChallengeService.GetSliceProof:input_type -> supernode.GetSliceProofRequest + 5, // 2: supernode.StorageChallengeService.VerifySliceProof:input_type -> supernode.VerifySliceProofRequest + 1, // 3: supernode.StorageChallengeService.GetCompoundProof:input_type -> supernode.GetCompoundProofRequest + 4, // 4: supernode.StorageChallengeService.GetSliceProof:output_type -> supernode.GetSliceProofResponse + 6, // 5: supernode.StorageChallengeService.VerifySliceProof:output_type -> supernode.VerifySliceProofResponse + 2, // 6: supernode.StorageChallengeService.GetCompoundProof:output_type -> supernode.GetCompoundProofResponse + 4, // [4:7] is the sub-list for method output_type + 1, // [1:4] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_supernode_storage_challenge_proto_init() } @@ -513,13 +987,99 @@ func file_supernode_storage_challenge_proto_init() { if File_supernode_storage_challenge_proto != nil { return } + if !protoimpl.UnsafeEnabled { + file_supernode_storage_challenge_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*ByteRange); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_storage_challenge_proto_msgTypes[1].Exporter = func(v any, i int) any { + switch v := v.(*GetCompoundProofRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_storage_challenge_proto_msgTypes[2].Exporter = func(v any, i int) any { + switch v := v.(*GetCompoundProofResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_storage_challenge_proto_msgTypes[3].Exporter = func(v any, i int) any { + switch v := v.(*GetSliceProofRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_storage_challenge_proto_msgTypes[4].Exporter = func(v any, i int) any { + switch v := v.(*GetSliceProofResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_storage_challenge_proto_msgTypes[5].Exporter = func(v any, i int) any { + switch v := v.(*VerifySliceProofRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_supernode_storage_challenge_proto_msgTypes[6].Exporter = func(v any, i int) any { + switch v := v.(*VerifySliceProofResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_supernode_storage_challenge_proto_rawDesc), len(file_supernode_storage_challenge_proto_rawDesc)), + RawDescriptor: file_supernode_storage_challenge_proto_rawDesc, NumEnums: 0, - NumMessages: 4, + NumMessages: 7, NumExtensions: 0, NumServices: 1, }, @@ -528,6 +1088,7 @@ func file_supernode_storage_challenge_proto_init() { MessageInfos: file_supernode_storage_challenge_proto_msgTypes, }.Build() File_supernode_storage_challenge_proto = out.File + file_supernode_storage_challenge_proto_rawDesc = nil file_supernode_storage_challenge_proto_goTypes = nil file_supernode_storage_challenge_proto_depIdxs = nil } diff --git a/gen/supernode/storage_challenge.swagger.json b/gen/supernode/storage_challenge.swagger.json index 9304b937..23d3083a 100644 --- a/gen/supernode/storage_challenge.swagger.json +++ b/gen/supernode/storage_challenge.swagger.json @@ -45,6 +45,73 @@ } } }, + "supernodeByteRange": { + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "uint64" + }, + "end": { + "type": "string", + "format": "uint64", + "title": "exclusive" + } + }, + "description": "ByteRange represents a half-open byte range [start, end) into an artifact." + }, + "supernodeGetCompoundProofResponse": { + "type": "object", + "properties": { + "challengeId": { + "type": "string" + }, + "epochId": { + "type": "string", + "format": "uint64" + }, + "ticketId": { + "type": "string" + }, + "artifactClass": { + "type": "integer", + "format": "int64" + }, + "artifactOrdinal": { + "type": "integer", + "format": "int64" + }, + "bucketType": { + "type": "integer", + "format": "int64" + }, + "artifactKey": { + "type": "string" + }, + "rangeBytes": { + "type": "array", + "items": { + "type": "string", + "format": "byte" + }, + "title": "i-th matches i-th request range" + }, + "proofHashHex": { + "type": "string", + "title": "BLAKE3(concat(range_bytes...)) lowercase hex" + }, + "recipientSignature": { + "type": "string", + "title": "recipient's keyring signature" + }, + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + }, "supernodeGetSliceProofResponse": { "type": "object", "properties": { diff --git a/gen/supernode/storage_challenge_grpc.pb.go b/gen/supernode/storage_challenge_grpc.pb.go index 0844b73d..1b67c0e1 100644 --- a/gen/supernode/storage_challenge_grpc.pb.go +++ b/gen/supernode/storage_challenge_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v3.21.12 +// - protoc v4.25.1 // source: supernode/storage_challenge.proto package supernode @@ -21,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( StorageChallengeService_GetSliceProof_FullMethodName = "/supernode.StorageChallengeService/GetSliceProof" StorageChallengeService_VerifySliceProof_FullMethodName = "/supernode.StorageChallengeService/VerifySliceProof" + StorageChallengeService_GetCompoundProof_FullMethodName = "/supernode.StorageChallengeService/GetCompoundProof" ) // StorageChallengeServiceClient is the client API for StorageChallengeService service. @@ -31,6 +32,7 @@ const ( type StorageChallengeServiceClient interface { GetSliceProof(ctx context.Context, in *GetSliceProofRequest, opts ...grpc.CallOption) (*GetSliceProofResponse, error) VerifySliceProof(ctx context.Context, in *VerifySliceProofRequest, opts ...grpc.CallOption) (*VerifySliceProofResponse, error) + GetCompoundProof(ctx context.Context, in *GetCompoundProofRequest, opts ...grpc.CallOption) (*GetCompoundProofResponse, error) } type storageChallengeServiceClient struct { @@ -61,6 +63,16 @@ func (c *storageChallengeServiceClient) VerifySliceProof(ctx context.Context, in return out, nil } +func (c *storageChallengeServiceClient) GetCompoundProof(ctx context.Context, in *GetCompoundProofRequest, opts ...grpc.CallOption) (*GetCompoundProofResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCompoundProofResponse) + err := c.cc.Invoke(ctx, StorageChallengeService_GetCompoundProof_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // StorageChallengeServiceServer is the server API for StorageChallengeService service. // All implementations must embed UnimplementedStorageChallengeServiceServer // for forward compatibility. @@ -69,6 +81,7 @@ func (c *storageChallengeServiceClient) VerifySliceProof(ctx context.Context, in type StorageChallengeServiceServer interface { GetSliceProof(context.Context, *GetSliceProofRequest) (*GetSliceProofResponse, error) VerifySliceProof(context.Context, *VerifySliceProofRequest) (*VerifySliceProofResponse, error) + GetCompoundProof(context.Context, *GetCompoundProofRequest) (*GetCompoundProofResponse, error) mustEmbedUnimplementedStorageChallengeServiceServer() } @@ -85,6 +98,9 @@ func (UnimplementedStorageChallengeServiceServer) GetSliceProof(context.Context, func (UnimplementedStorageChallengeServiceServer) VerifySliceProof(context.Context, *VerifySliceProofRequest) (*VerifySliceProofResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method VerifySliceProof not implemented") } +func (UnimplementedStorageChallengeServiceServer) GetCompoundProof(context.Context, *GetCompoundProofRequest) (*GetCompoundProofResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCompoundProof not implemented") +} func (UnimplementedStorageChallengeServiceServer) mustEmbedUnimplementedStorageChallengeServiceServer() { } func (UnimplementedStorageChallengeServiceServer) testEmbeddedByValue() {} @@ -143,6 +159,24 @@ func _StorageChallengeService_VerifySliceProof_Handler(srv interface{}, ctx cont return interceptor(ctx, in, info, handler) } +func _StorageChallengeService_GetCompoundProof_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCompoundProofRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageChallengeServiceServer).GetCompoundProof(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageChallengeService_GetCompoundProof_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageChallengeServiceServer).GetCompoundProof(ctx, req.(*GetCompoundProofRequest)) + } + return interceptor(ctx, in, info, handler) +} + // StorageChallengeService_ServiceDesc is the grpc.ServiceDesc for StorageChallengeService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -158,6 +192,10 @@ var StorageChallengeService_ServiceDesc = grpc.ServiceDesc{ MethodName: "VerifySliceProof", Handler: _StorageChallengeService_VerifySliceProof_Handler, }, + { + MethodName: "GetCompoundProof", + Handler: _StorageChallengeService_GetCompoundProof_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "supernode/storage_challenge.proto", diff --git a/go.mod b/go.mod index 96cee481..9db3b77b 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,11 @@ replace ( ) require ( + cosmossdk.io/errors v1.0.2 cosmossdk.io/math v1.5.3 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/DataDog/zstd v1.5.7 - github.com/LumeraProtocol/lumera v1.12.0-rc + github.com/LumeraProtocol/lumera v1.12.0 github.com/LumeraProtocol/rq-go v0.2.1 github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce github.com/cenkalti/backoff/v4 v4.3.0 @@ -55,7 +56,6 @@ require ( cosmossdk.io/collections v1.3.1 // indirect cosmossdk.io/core v0.11.3 // indirect cosmossdk.io/depinject v1.2.1 // indirect - cosmossdk.io/errors v1.0.2 // indirect cosmossdk.io/log v1.6.1 // indirect cosmossdk.io/schema v1.1.0 // indirect cosmossdk.io/store v1.1.2 // indirect diff --git a/go.sum b/go.sum index 12319b95..2f92226d 100644 --- a/go.sum +++ b/go.sum @@ -111,8 +111,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 h1:ig/FpDD2JofP/NExKQUbn7uOSZzJAQqogfqluZK4ed4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/LumeraProtocol/lumera v1.12.0-rc h1:Mfae496LpjYhf1SvAE/bsmtjgdoOD8WAJFRCier8xsg= -github.com/LumeraProtocol/lumera v1.12.0-rc/go.mod h1:/G9LTPZB+261tHoWoj7q+1fn+O/VV0zzagwLdsThSNo= +github.com/LumeraProtocol/lumera v1.12.0 h1:BHkPF/vCKyGFKtl2MMxtRpUyzraJ96rWY9FniTbG6cU= +github.com/LumeraProtocol/lumera v1.12.0/go.mod h1:/G9LTPZB+261tHoWoj7q+1fn+O/VV0zzagwLdsThSNo= github.com/LumeraProtocol/rq-go v0.2.1 h1:8B3UzRChLsGMmvZ+UVbJsJj6JZzL9P9iYxbdUwGsQI4= github.com/LumeraProtocol/rq-go v0.2.1/go.mod h1:APnKCZRh1Es2Vtrd2w4kCLgAyaL5Bqrkz/BURoRJ+O8= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= diff --git a/pkg/logtrace/log.go b/pkg/logtrace/log.go index 6e27b020..5263bf67 100644 --- a/pkg/logtrace/log.go +++ b/pkg/logtrace/log.go @@ -5,6 +5,7 @@ import ( "os" "runtime" "strings" + "sync/atomic" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -20,10 +21,14 @@ const CorrelationIDKey ContextKey = "correlation_id" const OriginKey ContextKey = "origin" var ( - logger *zap.Logger - minLevel zapcore.Level = zapcore.InfoLevel // effective minimum log level + loggerPtr atomic.Pointer[zap.Logger] + minLevel atomic.Int32 // effective minimum log level as zapcore.Level ) +func init() { + minLevel.Store(int32(zapcore.InfoLevel)) +} + // Setup initializes the logger for readable output in all modes. func Setup(serviceName string) { var err error @@ -42,20 +47,24 @@ func Setup(serviceName string) { // Always respect the LOG_LEVEL environment variable. lvl := getLogLevel() config.Level = zap.NewAtomicLevelAt(lvl) - // Persist the effective minimum so non-core sinks (e.g., Datadog) can - // filter entries consistently with the console logger. - minLevel = lvl - // Build the logger from the customized config. + var built *zap.Logger if tracingEnabled { - logger, err = config.Build(zap.AddCallerSkip(1), zap.AddStacktrace(zapcore.ErrorLevel)) + built, err = config.Build(zap.AddCallerSkip(1), zap.AddStacktrace(zapcore.ErrorLevel)) } else { - logger, err = config.Build() + built, err = config.Build() } if err != nil { panic(err) } + // Publish atomically so concurrent Setup/log calls cannot race on package + // globals. The effective minimum is stored after the logger so a racing log + // call always sees either the old complete pair or a conservative new logger + // with the previous Datadog gate for one call. + loggerPtr.Store(built) + minLevel.Store(int32(lvl)) + // Initialize Datadog forwarding (minimal integration in separate file) SetupDatadog(serviceName) } @@ -120,12 +129,17 @@ func extractCorrelationID(ctx context.Context) string { // logWithLevel logs a message with structured fields. func logWithLevel(level zapcore.Level, ctx context.Context, message string, fields Fields) { - if logger == nil { + lg := loggerPtr.Load() + if lg == nil { Setup("unknown-service") // Fallback if Setup wasn't called + lg = loggerPtr.Load() + if lg == nil { + return + } } // Drop early if below the configured level (keeps Datadog in sync) - if !logger.Core().Enabled(level) { + if !lg.Core().Enabled(level) { return } @@ -149,7 +163,7 @@ func logWithLevel(level zapcore.Level, ctx context.Context, message string, fiel } // Log with the structured fields using a level check/write - if ce := logger.Check(level, message); ce != nil { + if ce := lg.Check(level, message); ce != nil { ce.Write(zapFields...) } else { // Should not happen due to early Enabled check, but guard anyway @@ -159,7 +173,7 @@ func logWithLevel(level zapcore.Level, ctx context.Context, message string, fiel // Forward to Datadog (non-blocking, best-effort) only if level is enabled // for the current configuration. This prevents forwarding debug entries // when the logger is configured for info and above. - if level >= minLevel { + if int32(level) >= minLevel.Load() { ForwardDatadog(level, ctx, message, fields) } } diff --git a/pkg/logtrace/race_test.go b/pkg/logtrace/race_test.go new file mode 100644 index 00000000..75f86775 --- /dev/null +++ b/pkg/logtrace/race_test.go @@ -0,0 +1,27 @@ +//go:build race + +package logtrace + +import ( + "context" + "sync" + "testing" +) + +func TestSetupConcurrentWithLoggingRaceFree(t *testing.T) { + ctx := context.Background() + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if i%10 == 0 { + Setup("race-test") + } + Debug(ctx, "debug", Fields{"i": i}) + Info(ctx, "info", Fields{"i": i}) + Warn(ctx, "warn", Fields{"i": i}) + }(i) + } + wg.Wait() +} diff --git a/pkg/lumera/chainerrors/chainerrors.go b/pkg/lumera/chainerrors/chainerrors.go new file mode 100644 index 00000000..3457130e --- /dev/null +++ b/pkg/lumera/chainerrors/chainerrors.go @@ -0,0 +1,172 @@ +// Package chainerrors centralises classification of Lumera chain errors as +// surfaced to the supernode runtime. +// +// Background: +// +// The audit module on the chain uses cosmossdk.io/errors registered errors +// (e.g. audittypes.ErrHealOpInvalidState). Tx rejections come back through +// the cosmos tx pipeline carrying ABCI (codespace, code, raw_log) tuples; +// the supernode tx layer reconstructs the typed error via errorsmod.ABCIError +// and wraps it with %w so that errors.Is(err, audittypes.ErrXxx) works for +// callers (see pkg/lumera/modules/tx/impl.go BroadcastTransaction). +// +// Query rejections (gRPC) come back as standard google.golang.org/grpc/status +// errors, e.g. status.Error(codes.NotFound, "heal op not found") for the +// HealOp query in x/audit/v1/keeper/query_storage_truth.go. +// +// The predicates here: +// +// 1. Prefer typed sentinel matching via errors.Is. +// 2. Fall through to gRPC status codes for query-side rejections. +// 3. Keep an English-substring fallback so we remain correct against any +// currently-deployed chain build whose error path doesn't preserve the +// typed sentinel through the wire (defense-in-depth, removable once +// every chain build in production guarantees end-to-end ABCIError). +// +// IsTransientGrpc is the safety valve: any path that classifies an error as +// "definitely a chain-side reject" (and would therefore destructively clean +// up local state) MUST first check IsTransientGrpc and bail to retry on true. +package chainerrors + +import ( + "context" + "errors" + "strings" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// IsHealOpInvalidState reports whether err corresponds to the chain rejecting +// a heal-op state transition (e.g. "heal op status %s does not accept healer +// completion claim", "verification_hash is required", "heal op has no +// independent verifier assignments"). +// +// This is the chain's signal that our submit attempt was structurally +// invalid for the op's current chain state — it is NOT a transient error, +// callers may proceed to reconcile via GetHealOp. +func IsHealOpInvalidState(err error) bool { + if err == nil { + return false + } + if errors.Is(err, audittypes.ErrHealOpInvalidState) { + return true + } + // Substring fallback — match the discriminating phrase from + // x/audit/v1/keeper/msg_storage_truth.go:231. + return strings.Contains(err.Error(), "does not accept healer completion claim") +} + +// IsHealOpNotFound reports whether err corresponds to the chain reporting +// the queried heal op does not exist. This maps to BOTH: +// +// - gRPC status.Code(err) == codes.NotFound from query_storage_truth.go:78 +// - audittypes.ErrHealOpNotFound (registered code 11) from tx-side guards +// in msg_storage_truth.go:222, :278 +// +// Callers MUST first verify with IsTransientGrpc(err) — older code paths +// matched any error containing "not found" (gRPC "block N not found", codec +// lookup miss, key-not-found inside Cosmos SDK), which led to destructive +// cleanup on transient query failures. + +// IsHealOpPastDeadline reports whether err is the chain-side invalid-state +// rejection for a heal-op whose deadline has already passed. As of Lumera +// chain x/audit/v1/types/errors.go there is no dedicated past-deadline +// sentinel; the tx path uses ErrHealOpInvalidState for several heal-op +// rejections. Keep this predicate phrase-anchored so callers can short-circuit +// deadline rejects without treating every invalid-state error as expired. +func IsHealOpPastDeadline(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return errors.Is(err, audittypes.ErrHealOpInvalidState) && + strings.Contains(msg, "heal op") && + strings.Contains(msg, "deadline") +} + +func IsHealOpNotFound(err error) bool { + if err == nil { + return false + } + if errors.Is(err, audittypes.ErrHealOpNotFound) { + return true + } + if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound { + // Anchor on the chain's exact NotFound message to avoid catching + // unrelated "not found" errors that happen to be wrapped in a gRPC + // NotFound status. + return strings.Contains(st.Message(), "heal op not found") + } + // Final substring fallback — kept narrow on purpose (must contain + // "heal op" to avoid the broad "not found"/"not_found" trap from the + // previous implementation). + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "heal op not found") +} + +// IsHealVerificationAlreadySubmitted reports whether err corresponds to the +// chain rejecting a duplicate heal-verification submission from the same +// verifier (registered as audittypes.ErrHealVerificationExists, code 15, +// surfaced at msg_storage_truth.go:287). +func IsHealVerificationAlreadySubmitted(err error) bool { + if err == nil { + return false + } + if errors.Is(err, audittypes.ErrHealVerificationExists) { + return true + } + return strings.Contains(err.Error(), "verification already submitted by creator") +} + +// IsRecheckEvidenceAlreadySubmitted reports whether err corresponds to the +// chain rejecting a duplicate recheck-evidence submission. Chain wraps +// audittypes.ErrInvalidRecheckEvidence (a generic envelope for ALL recheck +// evidence rejections) with the discriminating phrase "recheck evidence +// already submitted for epoch %d ticket %q by %q" at +// msg_storage_truth.go:90. +// +// Because ErrInvalidRecheckEvidence is generic (covers many distinct rejects), +// we cannot collapse on errors.Is alone — we MUST disambiguate via the +// discriminating phrase. typed-OR-substring is therefore an "AND" only when +// the typed sentinel matches; otherwise we accept substring as the sole +// signal (handles older chain builds and double-wrapped errors). +func IsRecheckEvidenceAlreadySubmitted(err error) bool { + if err == nil { + return false + } + // Phrase match is required because ErrInvalidRecheckEvidence is a + // generic envelope for all recheck-evidence rejections (length, signer, + // hash) — we must disambiguate via the unique already-submitted phrase. + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "recheck evidence already submitted") +} + +// IsTransientGrpc reports whether err is a transient gRPC failure that +// callers should treat as "retry next tick" rather than "chain reject". +// +// Concretely: codes.Unavailable, codes.DeadlineExceeded, codes.Aborted, +// codes.ResourceExhausted, plus context.Canceled / context.DeadlineExceeded +// at the supernode side. Callers in the heal/verify/recheck paths must +// short-circuit on this BEFORE they classify an error as "chain-rejected, +// safe to clean up local state". +func IsTransientGrpc(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + if st, ok := status.FromError(err); ok { + switch st.Code() { + case codes.Unavailable, + codes.DeadlineExceeded, + codes.Aborted, + codes.ResourceExhausted, + codes.Canceled: + return true + } + } + return false +} diff --git a/pkg/lumera/chainerrors/chainerrors_test.go b/pkg/lumera/chainerrors/chainerrors_test.go new file mode 100644 index 00000000..c1d9ea16 --- /dev/null +++ b/pkg/lumera/chainerrors/chainerrors_test.go @@ -0,0 +1,180 @@ +package chainerrors + +import ( + "context" + "errors" + "fmt" + "testing" + + errorsmod "cosmossdk.io/errors" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// abciErr re-creates an error with typed sentinel preserved across %w wrap, +// matching the production wrap path in pkg/lumera/modules/tx/impl.go after +// the Wave 0 boundary fix. +func abciErr(sentinel *errorsmod.Error, rawLog string) error { + return fmt.Errorf("tx failed: code=%d codespace=%s height=0 gas_wanted=0 gas_used=0 raw_log=%s: %w", + sentinel.ABCICode(), sentinel.Codespace(), rawLog, sentinel) +} + +func TestIsHealOpInvalidState(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"typed sentinel", audittypes.ErrHealOpInvalidState, true}, + {"typed sentinel wrapped via fmt", fmt.Errorf("submit claim: %w", audittypes.ErrHealOpInvalidState), true}, + {"production wrap shape", abciErr(audittypes.ErrHealOpInvalidState, "heal op status HEALER_REPORTED does not accept healer completion claim"), true}, + {"substring fallback only", errors.New("rpc: heal op status FAILED does not accept healer completion claim (untyped)"), true}, + {"unrelated error", errors.New("network unreachable"), false}, + {"unrelated typed", audittypes.ErrHealOpNotFound, false}, + // Defensive: must NOT confuse with transient gRPC errors. + {"transient unavailable", status.Error(codes.Unavailable, "connection lost"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsHealOpInvalidState(tc.err); got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} + +func TestIsHealOpNotFound(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"typed sentinel", audittypes.ErrHealOpNotFound, true}, + {"typed sentinel wrapped via fmt", fmt.Errorf("get heal op: %w", audittypes.ErrHealOpNotFound), true}, + {"production tx wrap shape", abciErr(audittypes.ErrHealOpNotFound, "heal op 42 not found"), true}, + {"gRPC NotFound from chain query", status.Error(codes.NotFound, "heal op not found"), true}, + // Negative — the previous broad implementation matched these and + // caused destructive cleanup. The new predicate must NOT. + {"gRPC NotFound but unrelated message", status.Error(codes.NotFound, "block 12345 not found"), false}, + {"plain string with not_found but no heal op", errors.New("codec: key not_found"), false}, + {"transient unavailable", status.Error(codes.Unavailable, "connection lost"), false}, + {"context canceled", context.Canceled, false}, + {"unrelated error", errors.New("network unreachable"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsHealOpNotFound(tc.err); got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} + +func TestIsHealVerificationAlreadySubmitted(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"typed sentinel", audittypes.ErrHealVerificationExists, true}, + {"typed sentinel wrapped via fmt", fmt.Errorf("submit verification: %w", audittypes.ErrHealVerificationExists), true}, + {"production wrap shape", abciErr(audittypes.ErrHealVerificationExists, "verification already submitted by creator"), true}, + {"substring fallback only", errors.New("verification already submitted by creator (untyped)"), true}, + {"unrelated error", errors.New("rpc unauthorized"), false}, + {"transient unavailable", status.Error(codes.Unavailable, "connection lost"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsHealVerificationAlreadySubmitted(tc.err); got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} + +func TestIsRecheckEvidenceAlreadySubmitted(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"production wrap with phrase", abciErr(audittypes.ErrInvalidRecheckEvidence, "recheck evidence already submitted for epoch 7 ticket \"abc\" by \"lumera1...\""), true}, + {"substring only", errors.New("recheck evidence already submitted somewhere"), true}, + // Generic ErrInvalidRecheckEvidence WITHOUT the phrase covers many + // other rejections (length, signer, hash) — must NOT match. + {"typed sentinel without phrase (generic envelope)", audittypes.ErrInvalidRecheckEvidence, false}, + {"typed sentinel different reject phrase", fmt.Errorf("creator does not match expected: %w", audittypes.ErrInvalidRecheckEvidence), false}, + {"transient unavailable", status.Error(codes.Unavailable, "connection lost"), false}, + {"unrelated error", errors.New("network unreachable"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsRecheckEvidenceAlreadySubmitted(tc.err); got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} + +func TestIsTransientGrpc(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"context canceled", context.Canceled, true}, + {"context deadline", context.DeadlineExceeded, true}, + {"context canceled wrapped", fmt.Errorf("op aborted: %w", context.Canceled), true}, + {"grpc Unavailable", status.Error(codes.Unavailable, "connection lost"), true}, + {"grpc DeadlineExceeded", status.Error(codes.DeadlineExceeded, "rpc timed out"), true}, + {"grpc Aborted", status.Error(codes.Aborted, "tx aborted"), true}, + {"grpc ResourceExhausted", status.Error(codes.ResourceExhausted, "throttled"), true}, + {"grpc Canceled", status.Error(codes.Canceled, "client canceled"), true}, + // Definitely-not-transient cases. + {"grpc NotFound", status.Error(codes.NotFound, "heal op not found"), false}, + {"grpc InvalidArgument", status.Error(codes.InvalidArgument, "bad input"), false}, + {"typed audit error", audittypes.ErrHealOpInvalidState, false}, + {"plain string", errors.New("network unreachable"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsTransientGrpc(tc.err); got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} + +// TestRegression_TransientNotFoundDoesNotMatchHealOpNotFound is the +// regression test for C4 — the previous isChainHealOpNotFound matched any +// "not found" substring including transient gRPC errors, leading to +// destructive cleanup of healer staging dirs. +func TestRegression_TransientNotFoundDoesNotMatchHealOpNotFound(t *testing.T) { + transientCases := []error{ + errors.New("rpc error: block 12345 not found at height 7"), + errors.New("codec: key not_found in store"), + status.Error(codes.NotFound, "block at height 99 not found"), + } + for i, e := range transientCases { + if IsHealOpNotFound(e) { + t.Fatalf("case %d: transient %q must NOT classify as heal-op-not-found", i, e) + } + } +} + +func TestIsHealOpPastDeadline(t *testing.T) { + deadlineErr := fmt.Errorf("submit claim: %w", errorsmod.Wrap(audittypes.ErrHealOpInvalidState, "heal op deadline has passed")) + if !IsHealOpPastDeadline(deadlineErr) { + t.Fatalf("expected deadline invalid-state error to match") + } + stateErr := fmt.Errorf("submit claim: %w", errorsmod.Wrap(audittypes.ErrHealOpInvalidState, "heal op status VERIFIED does not accept healer completion claim")) + if IsHealOpPastDeadline(stateErr) { + t.Fatalf("generic invalid-state error must not be treated as deadline") + } +} diff --git a/pkg/lumera/modules/action/action_mock.go b/pkg/lumera/modules/action/action_mock.go index a4524fa3..e993fe40 100644 --- a/pkg/lumera/modules/action/action_mock.go +++ b/pkg/lumera/modules/action/action_mock.go @@ -85,3 +85,18 @@ func (mr *MockModuleMockRecorder) GetParams(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParams", reflect.TypeOf((*MockModule)(nil).GetParams), ctx) } + +// ListActionsBySuperNode mocks base method. +func (m *MockModule) ListActionsBySuperNode(ctx context.Context, superNodeAddress string) (*types.QueryListActionsBySuperNodeResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListActionsBySuperNode", ctx, superNodeAddress) + ret0, _ := ret[0].(*types.QueryListActionsBySuperNodeResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListActionsBySuperNode indicates an expected call of ListActionsBySuperNode. +func (mr *MockModuleMockRecorder) ListActionsBySuperNode(ctx, superNodeAddress any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListActionsBySuperNode", reflect.TypeOf((*MockModule)(nil).ListActionsBySuperNode), ctx, superNodeAddress) +} diff --git a/pkg/lumera/modules/action/impl.go b/pkg/lumera/modules/action/impl.go index ccf4dfea..6295e4f6 100644 --- a/pkg/lumera/modules/action/impl.go +++ b/pkg/lumera/modules/action/impl.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/LumeraProtocol/lumera/x/action/v1/types" + "github.com/cosmos/cosmos-sdk/types/query" "google.golang.org/grpc" ) @@ -57,3 +58,30 @@ func (m *module) GetParams(ctx context.Context) (*types.QueryParamsResponse, err return resp, nil } + +// ListActionsBySuperNode lists actions assigned to a specific supernode. +func (m *module) ListActionsBySuperNode(ctx context.Context, superNodeAddress string) (*types.QueryListActionsBySuperNodeResponse, error) { + var all []*types.Action + var nextKey []byte + for { + resp, err := m.client.ListActionsBySuperNode(ctx, &types.QueryListActionsBySuperNodeRequest{ + SuperNodeAddress: superNodeAddress, + Pagination: &query.PageRequest{ + Key: nextKey, + Limit: 100, + }, + }) + if err != nil { + return nil, err + } + if resp == nil { + return &types.QueryListActionsBySuperNodeResponse{Actions: all}, nil + } + all = append(all, resp.Actions...) + if resp.Pagination == nil || len(resp.Pagination.NextKey) == 0 { + resp.Actions = all + return resp, nil + } + nextKey = resp.Pagination.NextKey + } +} diff --git a/pkg/lumera/modules/action/interface.go b/pkg/lumera/modules/action/interface.go index 585c7bf8..2a6d7123 100644 --- a/pkg/lumera/modules/action/interface.go +++ b/pkg/lumera/modules/action/interface.go @@ -13,6 +13,7 @@ type Module interface { GetAction(ctx context.Context, actionID string) (*types.QueryGetActionResponse, error) GetActionFee(ctx context.Context, dataSize string) (*types.QueryGetActionFeeResponse, error) GetParams(ctx context.Context) (*types.QueryParamsResponse, error) + ListActionsBySuperNode(ctx context.Context, superNodeAddress string) (*types.QueryListActionsBySuperNodeResponse, error) } // NewModule creates a new Action module client diff --git a/pkg/lumera/modules/audit/impl.go b/pkg/lumera/modules/audit/impl.go index 06b26908..9b54e603 100644 --- a/pkg/lumera/modules/audit/impl.go +++ b/pkg/lumera/modules/audit/impl.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/cosmos/cosmos-sdk/types/query" "google.golang.org/grpc" ) @@ -73,3 +74,47 @@ func (m *module) GetEpochReport(ctx context.Context, epochID uint64, supernodeAc } return resp, nil } + +func (m *module) GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*types.QueryEpochReportsByReporterResponse, error) { + resp, err := m.client.EpochReportsByReporter(ctx, &types.QueryEpochReportsByReporterRequest{ + SupernodeAccount: reporterAccount, + EpochId: epochID, + FilterByEpochId: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to get epoch reports by reporter: %w", err) + } + return resp, nil +} + +func (m *module) GetHealOp(ctx context.Context, healOpID uint64) (*types.QueryHealOpResponse, error) { + resp, err := m.client.HealOp(ctx, &types.QueryHealOpRequest{ + HealOpId: healOpID, + }) + if err != nil { + return nil, fmt.Errorf("failed to get heal op: %w", err) + } + return resp, nil +} + +func (m *module) GetHealOpsByStatus(ctx context.Context, status types.HealOpStatus, pagination *query.PageRequest) (*types.QueryHealOpsByStatusResponse, error) { + resp, err := m.client.HealOpsByStatus(ctx, &types.QueryHealOpsByStatusRequest{ + Status: status, + Pagination: pagination, + }) + if err != nil { + return nil, fmt.Errorf("failed to get heal ops by status: %w", err) + } + return resp, nil +} + +func (m *module) GetHealOpsByTicket(ctx context.Context, ticketID string, pagination *query.PageRequest) (*types.QueryHealOpsByTicketResponse, error) { + resp, err := m.client.HealOpsByTicket(ctx, &types.QueryHealOpsByTicketRequest{ + TicketId: ticketID, + Pagination: pagination, + }) + if err != nil { + return nil, fmt.Errorf("failed to get heal ops by ticket: %w", err) + } + return resp, nil +} diff --git a/pkg/lumera/modules/audit/impl_test.go b/pkg/lumera/modules/audit/impl_test.go new file mode 100644 index 00000000..b57a817f --- /dev/null +++ b/pkg/lumera/modules/audit/impl_test.go @@ -0,0 +1,13 @@ +package audit + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewModuleRejectsNilConnection(t *testing.T) { + m, err := NewModule(nil) + require.Nil(t, m) + require.ErrorContains(t, err, "connection cannot be nil") +} diff --git a/pkg/lumera/modules/audit/interface.go b/pkg/lumera/modules/audit/interface.go index a5ac2939..48297164 100644 --- a/pkg/lumera/modules/audit/interface.go +++ b/pkg/lumera/modules/audit/interface.go @@ -4,6 +4,7 @@ import ( "context" "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/cosmos/cosmos-sdk/types/query" "google.golang.org/grpc" ) @@ -15,6 +16,12 @@ type Module interface { GetCurrentEpoch(ctx context.Context) (*types.QueryCurrentEpochResponse, error) GetAssignedTargets(ctx context.Context, supernodeAccount string, epochID uint64) (*types.QueryAssignedTargetsResponse, error) GetEpochReport(ctx context.Context, epochID uint64, supernodeAccount string) (*types.QueryEpochReportResponse, error) + GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*types.QueryEpochReportsByReporterResponse, error) + + // LEP-6 heal-op queries. + GetHealOp(ctx context.Context, healOpID uint64) (*types.QueryHealOpResponse, error) + GetHealOpsByStatus(ctx context.Context, status types.HealOpStatus, pagination *query.PageRequest) (*types.QueryHealOpsByStatusResponse, error) + GetHealOpsByTicket(ctx context.Context, ticketID string, pagination *query.PageRequest) (*types.QueryHealOpsByTicketResponse, error) } // NewModule creates a new Audit module client. diff --git a/pkg/lumera/modules/audit_msg/audit_msg_mock.go b/pkg/lumera/modules/audit_msg/audit_msg_mock.go index 246b3542..da3d1524 100644 --- a/pkg/lumera/modules/audit_msg/audit_msg_mock.go +++ b/pkg/lumera/modules/audit_msg/audit_msg_mock.go @@ -42,19 +42,34 @@ func (m *MockModule) EXPECT() *MockModuleMockRecorder { return m.recorder } +// ClaimHealComplete mocks base method. +func (m *MockModule) ClaimHealComplete(ctx context.Context, healOpID uint64, ticketID, healManifestHash, details string) (*tx.BroadcastTxResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClaimHealComplete", ctx, healOpID, ticketID, healManifestHash, details) + ret0, _ := ret[0].(*tx.BroadcastTxResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClaimHealComplete indicates an expected call of ClaimHealComplete. +func (mr *MockModuleMockRecorder) ClaimHealComplete(ctx, healOpID, ticketID, healManifestHash, details any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClaimHealComplete", reflect.TypeOf((*MockModule)(nil).ClaimHealComplete), ctx, healOpID, ticketID, healManifestHash, details) +} + // SubmitEpochReport mocks base method. -func (m *MockModule) SubmitEpochReport(ctx context.Context, epochID uint64, hostReport types.HostReport, storageChallengeObservations []*types.StorageChallengeObservation) (*tx.BroadcastTxResponse, error) { +func (m *MockModule) SubmitEpochReport(ctx context.Context, epochID uint64, hostReport types.HostReport, storageChallengeObservations []*types.StorageChallengeObservation, storageProofResults []*types.StorageProofResult) (*tx.BroadcastTxResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SubmitEpochReport", ctx, epochID, hostReport, storageChallengeObservations) + ret := m.ctrl.Call(m, "SubmitEpochReport", ctx, epochID, hostReport, storageChallengeObservations, storageProofResults) ret0, _ := ret[0].(*tx.BroadcastTxResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // SubmitEpochReport indicates an expected call of SubmitEpochReport. -func (mr *MockModuleMockRecorder) SubmitEpochReport(ctx, epochID, hostReport, storageChallengeObservations any) *gomock.Call { +func (mr *MockModuleMockRecorder) SubmitEpochReport(ctx, epochID, hostReport, storageChallengeObservations, storageProofResults any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitEpochReport", reflect.TypeOf((*MockModule)(nil).SubmitEpochReport), ctx, epochID, hostReport, storageChallengeObservations) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitEpochReport", reflect.TypeOf((*MockModule)(nil).SubmitEpochReport), ctx, epochID, hostReport, storageChallengeObservations, storageProofResults) } // SubmitEvidence mocks base method. @@ -71,3 +86,33 @@ func (mr *MockModuleMockRecorder) SubmitEvidence(ctx, subjectAddress, evidenceTy mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitEvidence", reflect.TypeOf((*MockModule)(nil).SubmitEvidence), ctx, subjectAddress, evidenceType, actionID, metadataJSON) } + +// SubmitHealVerification mocks base method. +func (m *MockModule) SubmitHealVerification(ctx context.Context, healOpID uint64, verified bool, verificationHash, details string) (*tx.BroadcastTxResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitHealVerification", ctx, healOpID, verified, verificationHash, details) + ret0, _ := ret[0].(*tx.BroadcastTxResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SubmitHealVerification indicates an expected call of SubmitHealVerification. +func (mr *MockModuleMockRecorder) SubmitHealVerification(ctx, healOpID, verified, verificationHash, details any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitHealVerification", reflect.TypeOf((*MockModule)(nil).SubmitHealVerification), ctx, healOpID, verified, verificationHash, details) +} + +// SubmitStorageRecheckEvidence mocks base method. +func (m *MockModule) SubmitStorageRecheckEvidence(ctx context.Context, epochID uint64, challengedSupernodeAccount, ticketID, challengedResultTranscriptHash, recheckTranscriptHash string, recheckResultClass types.StorageProofResultClass, details string) (*tx.BroadcastTxResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitStorageRecheckEvidence", ctx, epochID, challengedSupernodeAccount, ticketID, challengedResultTranscriptHash, recheckTranscriptHash, recheckResultClass, details) + ret0, _ := ret[0].(*tx.BroadcastTxResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SubmitStorageRecheckEvidence indicates an expected call of SubmitStorageRecheckEvidence. +func (mr *MockModuleMockRecorder) SubmitStorageRecheckEvidence(ctx, epochID, challengedSupernodeAccount, ticketID, challengedResultTranscriptHash, recheckTranscriptHash, recheckResultClass, details any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitStorageRecheckEvidence", reflect.TypeOf((*MockModule)(nil).SubmitStorageRecheckEvidence), ctx, epochID, challengedSupernodeAccount, ticketID, challengedResultTranscriptHash, recheckTranscriptHash, recheckResultClass, details) +} diff --git a/pkg/lumera/modules/audit_msg/impl.go b/pkg/lumera/modules/audit_msg/impl.go index 76585a8f..13623d86 100644 --- a/pkg/lumera/modules/audit_msg/impl.go +++ b/pkg/lumera/modules/audit_msg/impl.go @@ -80,7 +80,13 @@ func (m *module) SubmitEvidence(ctx context.Context, subjectAddress string, evid }) } -func (m *module) SubmitEpochReport(ctx context.Context, epochID uint64, hostReport audittypes.HostReport, storageChallengeObservations []*audittypes.StorageChallengeObservation) (*sdktx.BroadcastTxResponse, error) { +func (m *module) SubmitEpochReport( + ctx context.Context, + epochID uint64, + hostReport audittypes.HostReport, + storageChallengeObservations []*audittypes.StorageChallengeObservation, + storageProofResults []*audittypes.StorageProofResult, +) (*sdktx.BroadcastTxResponse, error) { m.mu.Lock() defer m.mu.Unlock() @@ -97,6 +103,113 @@ func (m *module) SubmitEpochReport(ctx context.Context, epochID uint64, hostRepo EpochId: epochID, HostReport: hostReport, StorageChallengeObservations: storageChallengeObservations, + StorageProofResults: storageProofResults, + }, nil + }) +} + +func (m *module) SubmitStorageRecheckEvidence( + ctx context.Context, + epochID uint64, + challengedSupernodeAccount string, + ticketID string, + challengedResultTranscriptHash string, + recheckTranscriptHash string, + recheckResultClass audittypes.StorageProofResultClass, + details string, +) (*sdktx.BroadcastTxResponse, error) { + challengedSupernodeAccount = strings.TrimSpace(challengedSupernodeAccount) + if challengedSupernodeAccount == "" { + return nil, fmt.Errorf("challenged supernode account cannot be empty") + } + ticketID = strings.TrimSpace(ticketID) + if ticketID == "" { + return nil, fmt.Errorf("ticket id cannot be empty") + } + challengedResultTranscriptHash = strings.TrimSpace(challengedResultTranscriptHash) + if challengedResultTranscriptHash == "" { + return nil, fmt.Errorf("challenged result transcript hash cannot be empty") + } + recheckTranscriptHash = strings.TrimSpace(recheckTranscriptHash) + if recheckTranscriptHash == "" { + return nil, fmt.Errorf("recheck transcript hash cannot be empty") + } + + m.mu.Lock() + defer m.mu.Unlock() + + return m.txHelper.ExecuteTransaction(ctx, func(creator string) (sdktypes.Msg, error) { + return &audittypes.MsgSubmitStorageRecheckEvidence{ + Creator: creator, + EpochId: epochID, + ChallengedSupernodeAccount: challengedSupernodeAccount, + TicketId: ticketID, + ChallengedResultTranscriptHash: challengedResultTranscriptHash, + RecheckTranscriptHash: recheckTranscriptHash, + RecheckResultClass: recheckResultClass, + Details: details, + }, nil + }) +} + +func (m *module) ClaimHealComplete( + ctx context.Context, + healOpID uint64, + ticketID string, + healManifestHash string, + details string, +) (*sdktx.BroadcastTxResponse, error) { + if healOpID == 0 { + return nil, fmt.Errorf("heal op id cannot be zero") + } + ticketID = strings.TrimSpace(ticketID) + if ticketID == "" { + return nil, fmt.Errorf("ticket id cannot be empty") + } + healManifestHash = strings.TrimSpace(healManifestHash) + if healManifestHash == "" { + return nil, fmt.Errorf("heal manifest hash cannot be empty") + } + + m.mu.Lock() + defer m.mu.Unlock() + + return m.txHelper.ExecuteTransaction(ctx, func(creator string) (sdktypes.Msg, error) { + return &audittypes.MsgClaimHealComplete{ + Creator: creator, + HealOpId: healOpID, + TicketId: ticketID, + HealManifestHash: healManifestHash, + Details: details, + }, nil + }) +} + +func (m *module) SubmitHealVerification( + ctx context.Context, + healOpID uint64, + verified bool, + verificationHash string, + details string, +) (*sdktx.BroadcastTxResponse, error) { + if healOpID == 0 { + return nil, fmt.Errorf("heal op id cannot be zero") + } + verificationHash = strings.TrimSpace(verificationHash) + if verificationHash == "" { + return nil, fmt.Errorf("verification hash cannot be empty") + } + + m.mu.Lock() + defer m.mu.Unlock() + + return m.txHelper.ExecuteTransaction(ctx, func(creator string) (sdktypes.Msg, error) { + return &audittypes.MsgSubmitHealVerification{ + Creator: creator, + HealOpId: healOpID, + Verified: verified, + VerificationHash: verificationHash, + Details: details, }, nil }) } diff --git a/pkg/lumera/modules/audit_msg/impl_test.go b/pkg/lumera/modules/audit_msg/impl_test.go new file mode 100644 index 00000000..0daab82c --- /dev/null +++ b/pkg/lumera/modules/audit_msg/impl_test.go @@ -0,0 +1,55 @@ +package audit_msg + +import ( + "context" + "strings" + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/stretchr/testify/require" +) + +func TestClaimHealCompleteValidatesInputsBeforeTxExecution(t *testing.T) { + m := &module{} + _, err := m.ClaimHealComplete(context.Background(), 0, "ticket", "manifest", "") + require.ErrorContains(t, err, "heal op id cannot be zero") + + _, err = m.ClaimHealComplete(context.Background(), 1, " ", "manifest", "") + require.ErrorContains(t, err, "ticket id cannot be empty") + + _, err = m.ClaimHealComplete(context.Background(), 1, "ticket", " ", "") + require.ErrorContains(t, err, "heal manifest hash cannot be empty") +} + +func TestSubmitHealVerificationValidatesInputsBeforeTxExecution(t *testing.T) { + m := &module{} + _, err := m.SubmitHealVerification(context.Background(), 0, true, "hash", "") + require.ErrorContains(t, err, "heal op id cannot be zero") + + _, err = m.SubmitHealVerification(context.Background(), 1, true, " ", "") + require.ErrorContains(t, err, "verification hash cannot be empty") +} + +func TestSubmitStorageRecheckEvidenceValidatesInputsBeforeTxExecution(t *testing.T) { + m := &module{} + _, err := m.SubmitStorageRecheckEvidence(context.Background(), 7, " ", "ticket", "challenged", "recheck", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL, "") + require.ErrorContains(t, err, "challenged supernode account cannot be empty") + + _, err = m.SubmitStorageRecheckEvidence(context.Background(), 7, "target", " ", "challenged", "recheck", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL, "") + require.ErrorContains(t, err, "ticket id cannot be empty") + + _, err = m.SubmitStorageRecheckEvidence(context.Background(), 7, "target", "ticket", " ", "recheck", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL, "") + require.ErrorContains(t, err, "challenged result transcript hash cannot be empty") + + _, err = m.SubmitStorageRecheckEvidence(context.Background(), 7, "target", "ticket", "challenged", strings.Repeat(" ", 3), audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL, "") + require.ErrorContains(t, err, "recheck transcript hash cannot be empty") +} + +func TestSubmitEvidenceValidatesInputsBeforeTxExecution(t *testing.T) { + m := &module{} + _, err := m.SubmitEvidence(context.Background(), " ", audittypes.EvidenceType_EVIDENCE_TYPE_UNSPECIFIED, "action", "{}") + require.ErrorContains(t, err, "subject address cannot be empty") + + _, err = m.SubmitEvidence(context.Background(), "subject", audittypes.EvidenceType_EVIDENCE_TYPE_UNSPECIFIED, "action", " ") + require.ErrorContains(t, err, "metadata cannot be empty") +} diff --git a/pkg/lumera/modules/audit_msg/interface.go b/pkg/lumera/modules/audit_msg/interface.go index a8fe5dc9..2d196aea 100644 --- a/pkg/lumera/modules/audit_msg/interface.go +++ b/pkg/lumera/modules/audit_msg/interface.go @@ -15,7 +15,39 @@ import ( // Module defines the interface for audit-related transactions. type Module interface { SubmitEvidence(ctx context.Context, subjectAddress string, evidenceType audittypes.EvidenceType, actionID string, metadataJSON string) (*sdktx.BroadcastTxResponse, error) - SubmitEpochReport(ctx context.Context, epochID uint64, hostReport audittypes.HostReport, storageChallengeObservations []*audittypes.StorageChallengeObservation) (*sdktx.BroadcastTxResponse, error) + SubmitEpochReport( + ctx context.Context, + epochID uint64, + hostReport audittypes.HostReport, + storageChallengeObservations []*audittypes.StorageChallengeObservation, + storageProofResults []*audittypes.StorageProofResult, + ) (*sdktx.BroadcastTxResponse, error) + + // LEP-6 storage-truth tx surface. + SubmitStorageRecheckEvidence( + ctx context.Context, + epochID uint64, + challengedSupernodeAccount string, + ticketID string, + challengedResultTranscriptHash string, + recheckTranscriptHash string, + recheckResultClass audittypes.StorageProofResultClass, + details string, + ) (*sdktx.BroadcastTxResponse, error) + ClaimHealComplete( + ctx context.Context, + healOpID uint64, + ticketID string, + healManifestHash string, + details string, + ) (*sdktx.BroadcastTxResponse, error) + SubmitHealVerification( + ctx context.Context, + healOpID uint64, + verified bool, + verificationHash string, + details string, + ) (*sdktx.BroadcastTxResponse, error) } // NewModule creates a new audit_msg module instance using default TxHelper diff --git a/pkg/lumera/modules/tx/helper.go b/pkg/lumera/modules/tx/helper.go index 0f6cf4c2..99a0dc7d 100644 --- a/pkg/lumera/modules/tx/helper.go +++ b/pkg/lumera/modules/tx/helper.go @@ -16,8 +16,14 @@ import ( ) const ( - sequenceMismatchMaxAttempts = 3 - sequenceMismatchRetryStep = 500 * time.Millisecond + // DefaultSequenceMismatchMaxAttempts is the historic default cap on + // the per-tx sequence-mismatch retry loop. + DefaultSequenceMismatchMaxAttempts = 3 + // MaxSequenceMismatchAttemptsCap is the hard safety cap operators + // cannot exceed via config (mirrors MaxGasAdjustmentAttemptsCap). + // Wave 1 M12 fix. + MaxSequenceMismatchAttemptsCap = 10 + sequenceMismatchRetryStep = 500 * time.Millisecond ) func sleepSequenceMismatchBackoff(ctx context.Context, attempt int) { @@ -58,6 +64,11 @@ type TxHelperConfig struct { GasPadding uint64 FeeDenom string GasPrice string + // SequenceMismatchMaxAttempts caps the per-tx sequence-mismatch retry + // loop. 0 → DefaultSequenceMismatchMaxAttempts. Hard ceiling + // MaxSequenceMismatchAttemptsCap mirrored in applyTxHelperDefaults + // AND UpdateConfig (M12 fix). + SequenceMismatchMaxAttempts int } // NewTxHelper creates a new transaction helper with the given configuration. @@ -67,16 +78,17 @@ func NewTxHelper(authmod auth.Module, txmod Module, config *TxHelperConfig) *TxH applied := applyTxHelperDefaults(config) txConfig := &TxConfig{ - ChainID: applied.ChainID, - Keyring: applied.Keyring, - KeyName: applied.KeyName, - GasLimit: applied.GasLimit, - GasAdjustment: applied.GasAdjustment, - GasAdjustmentMultiplier: applied.GasAdjustmentMultiplier, - GasAdjustmentMaxAttempts: applied.GasAdjustmentMaxAttempts, - GasPadding: applied.GasPadding, - FeeDenom: applied.FeeDenom, - GasPrice: applied.GasPrice, + ChainID: applied.ChainID, + Keyring: applied.Keyring, + KeyName: applied.KeyName, + GasLimit: applied.GasLimit, + GasAdjustment: applied.GasAdjustment, + GasAdjustmentMultiplier: applied.GasAdjustmentMultiplier, + GasAdjustmentMaxAttempts: applied.GasAdjustmentMaxAttempts, + GasPadding: applied.GasPadding, + FeeDenom: applied.FeeDenom, + GasPrice: applied.GasPrice, + SequenceMismatchMaxAttempts: applied.SequenceMismatchMaxAttempts, } return &TxHelper{ @@ -120,6 +132,15 @@ func applyTxHelperDefaults(cfg *TxHelperConfig) TxHelperConfig { // hard cap as a safety net to prevent runaway fee spend. out.GasAdjustmentMaxAttempts = MaxGasAdjustmentAttemptsCap } + if out.SequenceMismatchMaxAttempts <= 0 { + out.SequenceMismatchMaxAttempts = DefaultSequenceMismatchMaxAttempts + } + if out.SequenceMismatchMaxAttempts > MaxSequenceMismatchAttemptsCap { + // hard cap mirrors GasAdjustmentMaxAttempts pattern — prevents + // operator-tunable retry from running away under chain + // congestion (M12 fix). + out.SequenceMismatchMaxAttempts = MaxSequenceMismatchAttemptsCap + } if out.GasPadding == 0 { out.GasPadding = DefaultGasPadding } @@ -180,7 +201,14 @@ func (h *TxHelper) ExecuteTransaction( h.seqInit = true } - for attempt := 1; attempt <= sequenceMismatchMaxAttempts; attempt++ { + maxAttempts := h.config.SequenceMismatchMaxAttempts + if maxAttempts <= 0 { + maxAttempts = DefaultSequenceMismatchMaxAttempts + } + if maxAttempts > MaxSequenceMismatchAttemptsCap { + maxAttempts = MaxSequenceMismatchAttemptsCap + } + for attempt := 1; attempt <= maxAttempts; attempt++ { usedSequence := h.nextSequence accountInfo := &authtypes.BaseAccount{ @@ -223,7 +251,7 @@ func (h *TxHelper) ExecuteTransaction( } // If retry unavailable, bubble error - if attempt == sequenceMismatchMaxAttempts { + if attempt == maxAttempts { fields := logtrace.Fields{ "attempt": attempt, "used_sequence": usedSequence, @@ -234,7 +262,7 @@ func (h *TxHelper) ExecuteTransaction( } logtrace.Warn(ctx, "transaction sequence mismatch", fields) - return resp, fmt.Errorf("sequence mismatch after retry (%d attempts): %w", sequenceMismatchMaxAttempts, err) + return resp, fmt.Errorf("sequence mismatch after retry (%d attempts): %w", maxAttempts, err) } sleepSequenceMismatchBackoff(ctx, attempt) @@ -444,6 +472,14 @@ func (h *TxHelper) UpdateConfig(config *TxHelperConfig) { } h.config.GasAdjustmentMaxAttempts = config.GasAdjustmentMaxAttempts } + if config.SequenceMismatchMaxAttempts > 0 { + if config.SequenceMismatchMaxAttempts > MaxSequenceMismatchAttemptsCap { + // hard cap mirrors applyTxHelperDefaults (M12 fix) — operators + // cannot bypass the sequence-retry safety cap via reconfig. + config.SequenceMismatchMaxAttempts = MaxSequenceMismatchAttemptsCap + } + h.config.SequenceMismatchMaxAttempts = config.SequenceMismatchMaxAttempts + } if config.GasPadding != 0 { h.config.GasPadding = config.GasPadding } diff --git a/pkg/lumera/modules/tx/impl.go b/pkg/lumera/modules/tx/impl.go index ff9c0e1e..252fbf4b 100644 --- a/pkg/lumera/modules/tx/impl.go +++ b/pkg/lumera/modules/tx/impl.go @@ -10,6 +10,7 @@ import ( "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" lumeracodec "github.com/LumeraProtocol/supernode/v2/pkg/lumera/codec" + errorsmod "cosmossdk.io/errors" "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/client/tx" "github.com/cosmos/cosmos-sdk/types" @@ -203,16 +204,26 @@ func (m *module) BroadcastTransaction(ctx context.Context, txBytes []byte) (*sdk return nil, fmt.Errorf("failed to broadcast transaction: %w", err) } - // If the chain returns a non-zero code, surface it as an error with context + // If the chain returns a non-zero code, surface it as an error with context. + // We wrap with errorsmod.ABCIError so that callers can use errors.Is + // against typed sentinels (e.g. audittypes.ErrHealOpInvalidState) — the + // raw string-prefixed shape is preserved so existing OOG / sequence- + // mismatch substring matchers continue to work. if resp != nil && resp.TxResponse != nil && resp.TxResponse.Code != 0 { + typed := errorsmod.ABCIError( + resp.TxResponse.Codespace, + resp.TxResponse.Code, + resp.TxResponse.RawLog, + ) return resp, fmt.Errorf( - "tx failed: code=%d codespace=%s height=%d gas_wanted=%d gas_used=%d raw_log=%s", + "tx failed: code=%d codespace=%s height=%d gas_wanted=%d gas_used=%d raw_log=%s: %w", resp.TxResponse.Code, resp.TxResponse.Codespace, resp.TxResponse.Height, resp.TxResponse.GasWanted, resp.TxResponse.GasUsed, resp.TxResponse.RawLog, + typed, ) } diff --git a/pkg/lumera/modules/tx/interface.go b/pkg/lumera/modules/tx/interface.go index 28bac04f..a4179846 100644 --- a/pkg/lumera/modules/tx/interface.go +++ b/pkg/lumera/modules/tx/interface.go @@ -28,6 +28,11 @@ type TxConfig struct { GasPadding uint64 FeeDenom string GasPrice string + // SequenceMismatchMaxAttempts caps the per-tx sequence-mismatch retry + // loop in TxHelper.ExecuteTransaction. 0 means "use package default". + // Hard ceiling enforced in both applyTxHelperDefaults and UpdateConfig + // (M12 fix; mirrors the GasAdjustmentMaxAttempts safety-cap pattern). + SequenceMismatchMaxAttempts int } // Module defines the interface for transaction-related operations diff --git a/pkg/lumera/modules/tx/wave1_seq_cap_test.go b/pkg/lumera/modules/tx/wave1_seq_cap_test.go new file mode 100644 index 00000000..3b685eef --- /dev/null +++ b/pkg/lumera/modules/tx/wave1_seq_cap_test.go @@ -0,0 +1,48 @@ +package tx + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestApplyTxHelperDefaults_SequenceMismatchCap verifies the M12 fix: +// SequenceMismatchMaxAttempts is capped at MaxSequenceMismatchAttemptsCap +// in applyTxHelperDefaults, mirroring the GasAdjustmentMaxAttempts cap. +func TestApplyTxHelperDefaults_SequenceMismatchCap(t *testing.T) { + t.Run("zero defaults to package default", func(t *testing.T) { + out := applyTxHelperDefaults(&TxHelperConfig{}) + require.Equal(t, DefaultSequenceMismatchMaxAttempts, out.SequenceMismatchMaxAttempts) + }) + t.Run("explicit value preserved when below cap", func(t *testing.T) { + out := applyTxHelperDefaults(&TxHelperConfig{SequenceMismatchMaxAttempts: 5}) + require.Equal(t, 5, out.SequenceMismatchMaxAttempts) + }) + t.Run("over-cap clamped to MaxSequenceMismatchAttemptsCap", func(t *testing.T) { + out := applyTxHelperDefaults(&TxHelperConfig{SequenceMismatchMaxAttempts: 1000}) + require.Equal(t, MaxSequenceMismatchAttemptsCap, out.SequenceMismatchMaxAttempts) + }) +} + +// TestUpdateConfig_SequenceMismatchCapMirror verifies that runtime +// reconfiguration also honours the cap (the safety-cap-mirroring rule +// noted by Matee — operators must not be able to bypass the cap by +// re-sending a config). +func TestUpdateConfig_SequenceMismatchCapMirror(t *testing.T) { + h := NewTxHelper(nil, nil, &TxHelperConfig{ + ChainID: "test", KeyName: "k", SequenceMismatchMaxAttempts: 3, + }) + require.Equal(t, 3, h.GetConfig().SequenceMismatchMaxAttempts) + + // Try to push over cap via UpdateConfig — must clamp. + h.UpdateConfig(&TxHelperConfig{SequenceMismatchMaxAttempts: 1000}) + require.Equal(t, MaxSequenceMismatchAttemptsCap, h.GetConfig().SequenceMismatchMaxAttempts) + + // Below-cap update preserved. + h.UpdateConfig(&TxHelperConfig{SequenceMismatchMaxAttempts: 7}) + require.Equal(t, 7, h.GetConfig().SequenceMismatchMaxAttempts) + + // 0 means "leave as-is" (UpdateConfig only writes positive values). + h.UpdateConfig(&TxHelperConfig{SequenceMismatchMaxAttempts: 0}) + require.Equal(t, 7, h.GetConfig().SequenceMismatchMaxAttempts) +} diff --git a/pkg/metrics/lep6/metrics.go b/pkg/metrics/lep6/metrics.go new file mode 100644 index 00000000..3bf2b00c --- /dev/null +++ b/pkg/metrics/lep6/metrics.go @@ -0,0 +1,278 @@ +// Package lep6 owns in-process observability counters for the off-chain LEP-6 stack. +// +// The supernode repo does not expose service-specific Prometheus collectors today; +// comparable subsystems use structured logtrace calls plus typed in-process snapshots +// (for example p2p/kademlia handler counters surfaced through status). Keep LEP-6 +// aligned with that pattern: hot paths increment cheap atomic counters/gauges and +// tests/status/debug callers can inspect Snapshot(). +package lep6 + +import ( + "sort" + "strings" + "sync" + "sync/atomic" + "time" +) + +// MetricsSnapshot is a point-in-time copy of LEP-6 off-chain observability signals. +// Counter maps use stable label keys in the form documented on each field. +type MetricsSnapshot struct { + // Storage challenge / dispatcher — LEP-6 §§9-12. + DispatchResultsTotal map[string]uint64 // result_class + DispatchSignFailuresTotal map[string]uint64 // context (e.g. PASS, NO_ELIGIBLE) + DispatchInternalFailuresTotal map[string]uint64 // pre-derivation stage label + DispatchThrottledTotal map[string]uint64 // policy + DispatchEpochDurationMillisTotal map[string]uint64 // role + DispatchEpochDurationMillisMax map[string]uint64 // role + DispatchEpochDurationCount map[string]uint64 // role + TicketDiscoveryTotal map[string]uint64 // result + NoTicketProviderActive int64 + + // Self-healing — LEP-6 §§18-22. + HealClaimsSubmittedTotal map[string]uint64 // outcome + HealClaimsReconciledTotal uint64 + HealVerificationsSubmittedTotal map[string]uint64 // verified=,result= + HealVerificationsAlreadyExistsTotal uint64 + HealFinalizePublishesTotal uint64 + HealFinalizeCleanupsTotal map[string]uint64 // status + HealOrphanedStagingCleanupsTotal uint64 + SelfHealingPendingClaims int64 + SelfHealingStagingBytes int64 + + // Recheck — LEP-6 §12.3 and §15.1. + RecheckCandidatesFoundTotal uint64 + RecheckEvidenceSubmittedTotal map[string]uint64 // class=,outcome= + RecheckEvidenceAlreadySubmittedTotal uint64 + RecheckExecutionFailuresTotal map[string]uint64 // reason + RecheckPendingCandidates int64 +} + +type counterMap struct { + mu sync.RWMutex + m map[string]*atomic.Uint64 +} + +func (c *counterMap) inc(key string, delta uint64) { + key = normalizeLabel(key) + c.mu.RLock() + v := c.m[key] + c.mu.RUnlock() + if v == nil { + c.mu.Lock() + if c.m == nil { + c.m = make(map[string]*atomic.Uint64) + } + v = c.m[key] + if v == nil { + v = &atomic.Uint64{} + c.m[key] = v + } + c.mu.Unlock() + } + v.Add(delta) +} + +func (c *counterMap) setMax(key string, value uint64) { + key = normalizeLabel(key) + c.mu.RLock() + v := c.m[key] + c.mu.RUnlock() + if v == nil { + c.mu.Lock() + if c.m == nil { + c.m = make(map[string]*atomic.Uint64) + } + v = c.m[key] + if v == nil { + v = &atomic.Uint64{} + c.m[key] = v + } + c.mu.Unlock() + } + for { + old := v.Load() + if value <= old || v.CompareAndSwap(old, value) { + return + } + } +} + +func (c *counterMap) snapshot() map[string]uint64 { + c.mu.RLock() + defer c.mu.RUnlock() + out := make(map[string]uint64, len(c.m)) + keys := make([]string, 0, len(c.m)) + for k := range c.m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + out[k] = c.m[k].Load() + } + return out +} + +func (c *counterMap) reset() { + c.mu.Lock() + c.m = make(map[string]*atomic.Uint64) + c.mu.Unlock() +} + +var metrics = struct { + dispatchResults counterMap + dispatchSignFailures counterMap + dispatchInternalFailures counterMap + dispatchThrottled counterMap + dispatchEpochMillisTotal counterMap + dispatchEpochMillisMax counterMap + dispatchEpochCount counterMap + ticketDiscovery counterMap + noTicketProviderActive atomic.Int64 + + healClaimsSubmitted counterMap + healClaimsReconciled atomic.Uint64 + healVerificationsSubmitted counterMap + healVerificationsAlreadyExist atomic.Uint64 + healFinalizePublishes atomic.Uint64 + healFinalizeCleanups counterMap + healOrphanedStagingCleanups atomic.Uint64 + selfHealingPendingClaims atomic.Int64 + selfHealingStagingBytes atomic.Int64 + + recheckCandidatesFound atomic.Uint64 + recheckEvidenceSubmitted counterMap + recheckEvidenceAlreadySubmitted atomic.Uint64 + recheckExecutionFailures counterMap + recheckPendingCandidates atomic.Int64 +}{} + +// Reset clears all counters/gauges. It is intended for tests. +func Reset() { + metrics.dispatchResults.reset() + metrics.dispatchSignFailures.reset() + metrics.dispatchInternalFailures.reset() + metrics.dispatchThrottled.reset() + metrics.dispatchEpochMillisTotal.reset() + metrics.dispatchEpochMillisMax.reset() + metrics.dispatchEpochCount.reset() + metrics.ticketDiscovery.reset() + metrics.noTicketProviderActive.Store(0) + metrics.healClaimsSubmitted.reset() + metrics.healClaimsReconciled.Store(0) + metrics.healVerificationsSubmitted.reset() + metrics.healVerificationsAlreadyExist.Store(0) + metrics.healFinalizePublishes.Store(0) + metrics.healFinalizeCleanups.reset() + metrics.healOrphanedStagingCleanups.Store(0) + metrics.selfHealingPendingClaims.Store(0) + metrics.selfHealingStagingBytes.Store(0) + metrics.recheckCandidatesFound.Store(0) + metrics.recheckEvidenceSubmitted.reset() + metrics.recheckEvidenceAlreadySubmitted.Store(0) + metrics.recheckExecutionFailures.reset() + metrics.recheckPendingCandidates.Store(0) +} + +// Snapshot returns a consistent copy of current LEP-6 metrics. +func Snapshot() MetricsSnapshot { + return MetricsSnapshot{ + DispatchResultsTotal: metrics.dispatchResults.snapshot(), + DispatchSignFailuresTotal: metrics.dispatchSignFailures.snapshot(), + DispatchInternalFailuresTotal: metrics.dispatchInternalFailures.snapshot(), + DispatchThrottledTotal: metrics.dispatchThrottled.snapshot(), + DispatchEpochDurationMillisTotal: metrics.dispatchEpochMillisTotal.snapshot(), + DispatchEpochDurationMillisMax: metrics.dispatchEpochMillisMax.snapshot(), + DispatchEpochDurationCount: metrics.dispatchEpochCount.snapshot(), + TicketDiscoveryTotal: metrics.ticketDiscovery.snapshot(), + NoTicketProviderActive: metrics.noTicketProviderActive.Load(), + HealClaimsSubmittedTotal: metrics.healClaimsSubmitted.snapshot(), + HealClaimsReconciledTotal: metrics.healClaimsReconciled.Load(), + HealVerificationsSubmittedTotal: metrics.healVerificationsSubmitted.snapshot(), + HealVerificationsAlreadyExistsTotal: metrics.healVerificationsAlreadyExist.Load(), + HealFinalizePublishesTotal: metrics.healFinalizePublishes.Load(), + HealFinalizeCleanupsTotal: metrics.healFinalizeCleanups.snapshot(), + HealOrphanedStagingCleanupsTotal: metrics.healOrphanedStagingCleanups.Load(), + SelfHealingPendingClaims: metrics.selfHealingPendingClaims.Load(), + SelfHealingStagingBytes: metrics.selfHealingStagingBytes.Load(), + RecheckCandidatesFoundTotal: metrics.recheckCandidatesFound.Load(), + RecheckEvidenceSubmittedTotal: metrics.recheckEvidenceSubmitted.snapshot(), + RecheckEvidenceAlreadySubmittedTotal: metrics.recheckEvidenceAlreadySubmitted.Load(), + RecheckExecutionFailuresTotal: metrics.recheckExecutionFailures.snapshot(), + RecheckPendingCandidates: metrics.recheckPendingCandidates.Load(), + } +} + +func IncDispatchResult(resultClass string) { metrics.dispatchResults.inc(resultClass, 1) } +func IncDispatchSignFailure(context string) { metrics.dispatchSignFailures.inc(context, 1) } +func IncDispatchInternalFailure(stage string) { metrics.dispatchInternalFailures.inc(stage, 1) } +func IncDispatchThrottled(policy string, dropped int) { + if dropped > 0 { + metrics.dispatchThrottled.inc(policy, uint64(dropped)) + } +} +func ObserveDispatchEpochDuration(role string, duration time.Duration) { + if duration < 0 { + duration = 0 + } + millis := uint64(duration.Milliseconds()) + metrics.dispatchEpochMillisTotal.inc(role, millis) + metrics.dispatchEpochMillisMax.setMax(role, millis) + metrics.dispatchEpochCount.inc(role, 1) +} +func IncTicketDiscovery(result string) { metrics.ticketDiscovery.inc(result, 1) } +func SetNoTicketProviderActive(active bool) { + if active { + metrics.noTicketProviderActive.Store(1) + } else { + metrics.noTicketProviderActive.Store(0) + } +} + +func IncHealClaim(outcome string) { metrics.healClaimsSubmitted.inc(outcome, 1) } +func IncHealClaimReconciled() { metrics.healClaimsReconciled.Add(1) } +func IncHealVerification(outcome string, verified bool) { + vote := "negative" + if verified { + vote = "positive" + } + metrics.healVerificationsSubmitted.inc("verified="+vote+",result="+normalizeLabel(outcome), 1) +} +func IncHealVerificationAlreadyExists() { metrics.healVerificationsAlreadyExist.Add(1) } +func IncHealFinalizePublish() { metrics.healFinalizePublishes.Add(1) } +func IncHealFinalizeCleanup(status string) { metrics.healFinalizeCleanups.inc(status, 1) } +func IncHealOrphanedStagingCleanup() { metrics.healOrphanedStagingCleanups.Add(1) } +func SetSelfHealingPendingClaims(count int) { + metrics.selfHealingPendingClaims.Store(nonNegativeInt64(count)) +} +func SetSelfHealingStagingBytes(bytes int64) { + if bytes < 0 { + bytes = 0 + } + metrics.selfHealingStagingBytes.Store(bytes) +} + +func IncRecheckCandidateFound() { metrics.recheckCandidatesFound.Add(1) } +func IncRecheckSubmission(resultClass, outcome string) { + metrics.recheckEvidenceSubmitted.inc("class="+normalizeLabel(resultClass)+",outcome="+normalizeLabel(outcome), 1) +} +func IncRecheckAlreadySubmitted() { metrics.recheckEvidenceAlreadySubmitted.Add(1) } +func IncRecheckFailure(reason string) { metrics.recheckExecutionFailures.inc(reason, 1) } +func SetRecheckPendingCandidates(count int) { + metrics.recheckPendingCandidates.Store(nonNegativeInt64(count)) +} + +func normalizeLabel(label string) string { + label = strings.TrimSpace(strings.ToLower(label)) + if label == "" { + return "unknown" + } + return label +} + +func nonNegativeInt64(v int) int64 { + if v < 0 { + return 0 + } + return int64(v) +} diff --git a/pkg/metrics/lep6/metrics_test.go b/pkg/metrics/lep6/metrics_test.go new file mode 100644 index 00000000..d82a7485 --- /dev/null +++ b/pkg/metrics/lep6/metrics_test.go @@ -0,0 +1,92 @@ +package lep6 + +import ( + "testing" + "time" +) + +func TestSnapshotTracksFullLEP6SignalSet(t *testing.T) { + Reset() + + IncDispatchResult("PASS") + IncDispatchThrottled("drop-non-RECENT-first", 3) + ObserveDispatchEpochDuration("challenger", 1500*time.Millisecond) + ObserveDispatchEpochDuration("challenger", 500*time.Millisecond) + IncTicketDiscovery("eligible") + SetNoTicketProviderActive(true) + + IncHealClaim("submitted") + IncHealClaimReconciled() + IncHealVerification("submitted", true) + IncHealVerification("dedup", false) + IncHealVerificationAlreadyExists() + IncHealFinalizePublish() + IncHealFinalizeCleanup("FAILED") + SetSelfHealingPendingClaims(2) + SetSelfHealingStagingBytes(4096) + + IncRecheckCandidateFound() + IncRecheckSubmission("RECHECK_CONFIRMED_FAIL", "submitted") + IncRecheckAlreadySubmitted() + IncRecheckFailure("execute") + SetRecheckPendingCandidates(7) + + s := Snapshot() + assertCounter(t, s.DispatchResultsTotal, "pass", 1) + assertCounter(t, s.DispatchThrottledTotal, "drop-non-recent-first", 3) + assertCounter(t, s.DispatchEpochDurationMillisTotal, "challenger", 2000) + assertCounter(t, s.DispatchEpochDurationMillisMax, "challenger", 1500) + assertCounter(t, s.DispatchEpochDurationCount, "challenger", 2) + assertCounter(t, s.TicketDiscoveryTotal, "eligible", 1) + if s.NoTicketProviderActive != 1 { + t.Fatalf("NoTicketProviderActive = %d, want 1", s.NoTicketProviderActive) + } + assertCounter(t, s.HealClaimsSubmittedTotal, "submitted", 1) + if s.HealClaimsReconciledTotal != 1 { + t.Fatalf("HealClaimsReconciledTotal = %d, want 1", s.HealClaimsReconciledTotal) + } + assertCounter(t, s.HealVerificationsSubmittedTotal, "verified=positive,result=submitted", 1) + assertCounter(t, s.HealVerificationsSubmittedTotal, "verified=negative,result=dedup", 1) + if s.HealVerificationsAlreadyExistsTotal != 1 { + t.Fatalf("HealVerificationsAlreadyExistsTotal = %d, want 1", s.HealVerificationsAlreadyExistsTotal) + } + if s.HealFinalizePublishesTotal != 1 { + t.Fatalf("HealFinalizePublishesTotal = %d, want 1", s.HealFinalizePublishesTotal) + } + assertCounter(t, s.HealFinalizeCleanupsTotal, "failed", 1) + if s.SelfHealingPendingClaims != 2 || s.SelfHealingStagingBytes != 4096 { + t.Fatalf("self-healing gauges = (%d,%d), want (2,4096)", s.SelfHealingPendingClaims, s.SelfHealingStagingBytes) + } + if s.RecheckCandidatesFoundTotal != 1 { + t.Fatalf("RecheckCandidatesFoundTotal = %d, want 1", s.RecheckCandidatesFoundTotal) + } + assertCounter(t, s.RecheckEvidenceSubmittedTotal, "class=recheck_confirmed_fail,outcome=submitted", 1) + if s.RecheckEvidenceAlreadySubmittedTotal != 1 { + t.Fatalf("RecheckEvidenceAlreadySubmittedTotal = %d, want 1", s.RecheckEvidenceAlreadySubmittedTotal) + } + assertCounter(t, s.RecheckExecutionFailuresTotal, "execute", 1) + if s.RecheckPendingCandidates != 7 { + t.Fatalf("RecheckPendingCandidates = %d, want 7", s.RecheckPendingCandidates) + } +} + +func TestResetClearsMetrics(t *testing.T) { + Reset() + IncDispatchResult("PASS") + SetSelfHealingPendingClaims(9) + Reset() + s := Snapshot() + if len(s.DispatchResultsTotal) != 0 { + t.Fatalf("DispatchResultsTotal after Reset = %#v, want empty", s.DispatchResultsTotal) + } + if s.SelfHealingPendingClaims != 0 { + t.Fatalf("SelfHealingPendingClaims after Reset = %d, want 0", s.SelfHealingPendingClaims) + } +} + +func assertCounter(t *testing.T, got map[string]uint64, key string, want uint64) { + t.Helper() + if got[key] != want { + t.Fatalf("counter[%q] = %d, want %d (all=%#v)", key, got[key], want, got) + } +} diff --git a/pkg/netutil/hostport.go b/pkg/netutil/hostport.go new file mode 100644 index 00000000..0945103b --- /dev/null +++ b/pkg/netutil/hostport.go @@ -0,0 +1,76 @@ +package netutil + +import ( + "net" + "net/url" + "strconv" + "strings" +) + +// ParseHostAndPort parses a raw host/address into host and port. +// +// Accepted inputs include: +// - "host" (uses defaultPort) +// - "host:1234" +// - "scheme://host:1234/path" (uses URL host portion) +// - "[2001:db8::1]:1234" +// - "[2001:db8::1]" (uses defaultPort) +// - "fe80::1%eth0" (IPv6 literal with zone, uses defaultPort) +// +// If a port is present but invalid, the parser falls back to defaultPort for +// compatibility with the existing storage-challenge address parser. +func ParseHostAndPort(address string, defaultPort int) (host string, port int, ok bool) { + address = strings.TrimSpace(address) + if address == "" { + return "", 0, false + } + + // If it looks like a URL, parse and use the host[:port] portion. + if u, err := url.Parse(address); err == nil && u.Host != "" { + address = u.Host + } + + if h, p, err := net.SplitHostPort(address); err == nil { + h = strings.TrimSpace(h) + if h == "" { + return "", 0, false + } + if n, err := strconv.Atoi(p); err == nil && n > 0 && n <= 65535 { + return h, n, true + } + return h, defaultPort, true + } + + // No port present. Treat it as a raw host if it is plausibly valid; otherwise fail. + host = strings.TrimSpace(address) + if host == "" { + return "", 0, false + } + + // Accept bracketed IPv6 literal without a port (e.g. "[2001:db8::1]") by stripping brackets. + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") && strings.Count(host, "]") == 1 { + host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + host = strings.TrimSpace(host) + if host == "" { + return "", 0, false + } + } + + // Reject obviously malformed inputs (paths, fragments, userinfo, whitespace, or stray brackets). + if strings.ContainsAny(host, " \t\r\n/\\?#@[]") { + return "", 0, false + } + + // If it contains ':' it must be a valid IPv6 literal (optionally with a zone, e.g. "fe80::1%eth0"). + if strings.Contains(host, ":") { + ipPart := host + if i := strings.IndexByte(ipPart, '%'); i >= 0 { + ipPart = ipPart[:i] + } + if net.ParseIP(ipPart) == nil { + return "", 0, false + } + } + + return host, defaultPort, true +} diff --git a/pkg/netutil/hostport_test.go b/pkg/netutil/hostport_test.go new file mode 100644 index 00000000..ec288ac6 --- /dev/null +++ b/pkg/netutil/hostport_test.go @@ -0,0 +1,39 @@ +package netutil + +import "testing" + +func TestParseHostAndPort(t *testing.T) { + tests := []struct { + name string + address string + defaultPort int + wantHost string + wantPort int + wantOK bool + }{ + {name: "host without port", address: "sn.example.com", defaultPort: 9090, wantHost: "sn.example.com", wantPort: 9090, wantOK: true}, + {name: "host with port", address: "sn.example.com:1234", defaultPort: 9090, wantHost: "sn.example.com", wantPort: 1234, wantOK: true}, + {name: "url host portion", address: "grpc://sn.example.com:2345/path", defaultPort: 9090, wantHost: "sn.example.com", wantPort: 2345, wantOK: true}, + {name: "bracketed ipv6 with port", address: "[2001:db8::1]:3456", defaultPort: 9090, wantHost: "2001:db8::1", wantPort: 3456, wantOK: true}, + {name: "bracketed ipv6 without port", address: "[2001:db8::1]", defaultPort: 9090, wantHost: "2001:db8::1", wantPort: 9090, wantOK: true}, + {name: "ipv6 with zone", address: "fe80::1%eth0", defaultPort: 9090, wantHost: "fe80::1%eth0", wantPort: 9090, wantOK: true}, + {name: "invalid port falls back", address: "sn.example.com:notaport", defaultPort: 9090, wantHost: "sn.example.com", wantPort: 9090, wantOK: true}, + {name: "empty", address: " ", defaultPort: 9090, wantOK: false}, + {name: "path rejected", address: "sn.example.com/path", defaultPort: 9090, wantOK: false}, + {name: "userinfo rejected", address: "user@sn.example.com", defaultPort: 9090, wantOK: false}, + {name: "stray bracket rejected", address: "sn.example.com]", defaultPort: 9090, wantOK: false}, + {name: "malformed ipv6 rejected", address: "2001:db8:::bad", defaultPort: 9090, wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHost, gotPort, gotOK := ParseHostAndPort(tt.address, tt.defaultPort) + if gotOK != tt.wantOK { + t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK) + } + if gotHost != tt.wantHost || gotPort != tt.wantPort { + t.Fatalf("ParseHostAndPort() = (%q, %d, %v), want (%q, %d, %v)", gotHost, gotPort, gotOK, tt.wantHost, tt.wantPort, tt.wantOK) + } + }) + } +} diff --git a/pkg/storage/queries/local.go b/pkg/storage/queries/local.go index e677de76..f6ff89c7 100644 --- a/pkg/storage/queries/local.go +++ b/pkg/storage/queries/local.go @@ -13,4 +13,7 @@ type LocalStoreInterface interface { StorageChallengeQueries PingHistoryQueries HealthCheckChallengeQueries + LEP6HealQueries + RecheckQueries + StorageChallengeStateQueries } diff --git a/pkg/storage/queries/recheck.go b/pkg/storage/queries/recheck.go new file mode 100644 index 00000000..bfe4fafc --- /dev/null +++ b/pkg/storage/queries/recheck.go @@ -0,0 +1,337 @@ +package queries + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +type RecheckSubmissionRecord struct { + EpochID uint64 + TicketID string + TargetAccount string + ChallengedTranscriptHash string + RecheckTranscriptHash string + ResultClass audittypes.StorageProofResultClass + SubmittedAt int64 + Status string +} + +// ErrLEP6RecheckAlreadyRecorded is returned by RecordPendingRecheckSubmission +// when a row already exists for (epoch_id, ticket_id, target_account). The +// caller (recheck attestor) treats this as "another tick already pre-staged +// this candidate" — same idempotency semantics as +// ErrLEP6ClaimAlreadyRecorded / ErrLEP6VerificationAlreadyRecorded. +// +// Wave 1 fix for L3: previous code used `INSERT OR IGNORE` which silently +// hid duplicates AND any real INSERT error (constraint violation, locked +// DB), then the caller submitted to chain anyway — the chain rejected and +// we deleted the row. Now duplicates are surfaced as a typed error and +// real INSERT failures propagate. +var ErrLEP6RecheckAlreadyRecorded = errors.New("lep6: recheck submission already recorded") + +const createStorageRecheckSubmissions = ` +CREATE TABLE IF NOT EXISTS storage_recheck_submissions ( + epoch_id INTEGER NOT NULL, + ticket_id TEXT NOT NULL, + target_account TEXT NOT NULL, + challenged_transcript_hash TEXT NOT NULL, + recheck_transcript_hash TEXT NOT NULL, + result_class INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'submitted', + submitted_at INTEGER NOT NULL, + PRIMARY KEY (epoch_id, ticket_id, target_account) +);` + +const createStorageRecheckSubmissionStatusIndex = `CREATE INDEX IF NOT EXISTS idx_storage_recheck_submissions_status ON storage_recheck_submissions(status);` +const alterStorageRecheckSubmissionStatus = `ALTER TABLE storage_recheck_submissions ADD COLUMN status TEXT NOT NULL DEFAULT 'submitted';` + +const createRecheckAttemptFailures = ` +CREATE TABLE IF NOT EXISTS recheck_attempt_failures ( + epoch_id INTEGER NOT NULL, + ticket_id TEXT NOT NULL, + target_account TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 1, + last_error TEXT, + expires_at INTEGER NOT NULL, + PRIMARY KEY (epoch_id, ticket_id, target_account) +);` + +const createRecheckAttemptFailuresExpiresIndex = `CREATE INDEX IF NOT EXISTS idx_recheck_attempt_failures_expires ON recheck_attempt_failures(expires_at);` + +func migrateRecheckAttemptFailuresPK(ctx context.Context, db sqliteExecQuerier) error { + pkCols, err := primaryKeyColumns(ctx, db, "recheck_attempt_failures") + if err != nil { + return err + } + hasTarget := false + for _, c := range pkCols { + if c == "target_account" { + hasTarget = true + break + } + } + if hasTarget { + return nil + } + if len(pkCols) == 0 { + return fmt.Errorf("recheck_attempt_failures has no detectable primary key") + } + exec, ok := db.(interface { + BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) + }) + if !ok { + return fmt.Errorf("recheck_attempt_failures migration: db handle does not support BeginTx") + } + tx, err := exec.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin recheck failure migration tx: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + if _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS recheck_attempt_failures_new;`); err != nil { + return fmt.Errorf("drop stale recheck failure migration table: %w", err) + } + const createNew = ` +CREATE TABLE recheck_attempt_failures_new ( + epoch_id INTEGER NOT NULL, + ticket_id TEXT NOT NULL, + target_account TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 1, + last_error TEXT, + expires_at INTEGER NOT NULL, + PRIMARY KEY (epoch_id, ticket_id, target_account) +);` + if _, err := tx.ExecContext(ctx, createNew); err != nil { + return fmt.Errorf("create new recheck failure table: %w", err) + } + const copyData = ` +INSERT INTO recheck_attempt_failures_new + (epoch_id, ticket_id, target_account, attempts, last_error, expires_at) +SELECT epoch_id, ticket_id, target_account, attempts, last_error, expires_at +FROM recheck_attempt_failures;` + if _, err := tx.ExecContext(ctx, copyData); err != nil { + return fmt.Errorf("copy recheck failure rows: %w", err) + } + if _, err := tx.ExecContext(ctx, `DROP TABLE recheck_attempt_failures;`); err != nil { + return fmt.Errorf("drop old recheck failure table: %w", err) + } + if _, err := tx.ExecContext(ctx, `ALTER TABLE recheck_attempt_failures_new RENAME TO recheck_attempt_failures;`); err != nil { + return fmt.Errorf("rename new recheck failure table: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit recheck failure migration: %w", err) + } + committed = true + return nil +} + +// migrateStorageRecheckSubmissionsPK migrates an old DB whose +// storage_recheck_submissions table has PK (epoch_id, ticket_id) up to the +// Wave 1 schema with PK (epoch_id, ticket_id, target_account). +// +// SQLite cannot ALTER PRIMARY KEY in place; we rebuild via the canonical +// "create _new, copy, drop, rename" pattern inside a single transaction so +// a crash mid-migration leaves the DB consistent. +// +// Idempotent: if the table is already on the new PK shape, this returns +// nil after the PRAGMA introspection check (no DDL run). +// +// Wave 1 fix for C2. +func migrateStorageRecheckSubmissionsPK(ctx context.Context, db sqliteExecQuerier) error { + pkCols, err := primaryKeyColumns(ctx, db, "storage_recheck_submissions") + if err != nil { + return err + } + hasTarget := false + for _, c := range pkCols { + if c == "target_account" { + hasTarget = true + break + } + } + if hasTarget { + return nil // already migrated + } + if len(pkCols) == 0 { + // Defensive: PRAGMA returned no PK columns. The CREATE TABLE + // above always sets a PK so this would only happen on a bizarre + // custom build; bail rather than silently rebuild. + return fmt.Errorf("storage_recheck_submissions has no detectable primary key") + } + + // Run inside a transaction so we don't end up with the new table but + // the old data partially copied. + exec, ok := db.(interface { + BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) + }) + if !ok { + return fmt.Errorf("storage_recheck_submissions migration: db handle does not support BeginTx") + } + tx, err := exec.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin migration tx: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + const createNew = ` +CREATE TABLE storage_recheck_submissions_new ( + epoch_id INTEGER NOT NULL, + ticket_id TEXT NOT NULL, + target_account TEXT NOT NULL, + challenged_transcript_hash TEXT NOT NULL, + recheck_transcript_hash TEXT NOT NULL, + result_class INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'submitted', + submitted_at INTEGER NOT NULL, + PRIMARY KEY (epoch_id, ticket_id, target_account) +);` + if _, err := tx.ExecContext(ctx, createNew); err != nil { + return fmt.Errorf("create new recheck table: %w", err) + } + const copyData = ` +INSERT INTO storage_recheck_submissions_new + (epoch_id, ticket_id, target_account, challenged_transcript_hash, recheck_transcript_hash, result_class, status, submitted_at) +SELECT + epoch_id, ticket_id, target_account, challenged_transcript_hash, recheck_transcript_hash, result_class, + COALESCE(status, 'submitted'), submitted_at +FROM storage_recheck_submissions;` + if _, err := tx.ExecContext(ctx, copyData); err != nil { + return fmt.Errorf("copy recheck rows: %w", err) + } + if _, err := tx.ExecContext(ctx, `DROP TABLE storage_recheck_submissions;`); err != nil { + return fmt.Errorf("drop old recheck table: %w", err) + } + if _, err := tx.ExecContext(ctx, `ALTER TABLE storage_recheck_submissions_new RENAME TO storage_recheck_submissions;`); err != nil { + return fmt.Errorf("rename new recheck table: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit recheck migration: %w", err) + } + committed = true + return nil +} + +// HasRecheckSubmission reports whether a row exists for the +// (epoch_id, ticket_id, target_account) tuple — Wave 1 fix for C2 (chain +// dedup is per-target, so multiple targets in one (epoch, ticket) must +// each be tracked separately). +func (s *SQLiteStore) HasRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount string) (bool, error) { + const stmt = `SELECT 1 FROM storage_recheck_submissions WHERE epoch_id = ? AND ticket_id = ? AND target_account = ? LIMIT 1` + var one int + err := s.db.QueryRowContext(ctx, stmt, epochID, ticketID, targetAccount).Scan(&one) + if err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, err + } + return true, nil +} + +// RecordPendingRecheckSubmission pre-stages a recheck submission row before +// chain submit. Returns ErrLEP6RecheckAlreadyRecorded when a row already +// exists for the (epoch, ticket, target) tuple — Wave 1 fix for L3 (no +// more silent INSERT-OR-IGNORE). +func (s *SQLiteStore) RecordPendingRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash string, resultClass audittypes.StorageProofResultClass) error { + return s.recordRecheckSubmissionWithStatus(ctx, epochID, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash, resultClass, "pending", true) +} + +// RecordRecheckSubmission records a submitted recheck row directly. Used by +// tests / direct paths. Idempotent (no error on duplicate) to preserve +// pre-Wave-1 caller behaviour. +func (s *SQLiteStore) RecordRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash string, resultClass audittypes.StorageProofResultClass) error { + return s.recordRecheckSubmissionWithStatus(ctx, epochID, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash, resultClass, "submitted", false) +} + +func (s *SQLiteStore) recordRecheckSubmissionWithStatus(ctx context.Context, epochID uint64, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash string, resultClass audittypes.StorageProofResultClass, status string, surfaceConflict bool) error { + if epochID == 0 || ticketID == "" { + return fmt.Errorf("epoch_id and ticket_id are required") + } + const stmt = `INSERT INTO storage_recheck_submissions + (epoch_id, ticket_id, target_account, challenged_transcript_hash, recheck_transcript_hash, result_class, status, submitted_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(epoch_id, ticket_id, target_account) DO NOTHING` + res, err := s.db.ExecContext(ctx, stmt, epochID, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash, int32(resultClass), status, time.Now().Unix()) + if err != nil { + return fmt.Errorf("insert recheck submission: %w", err) + } + if surfaceConflict { + n, raErr := res.RowsAffected() + if raErr == nil && n == 0 { + return ErrLEP6RecheckAlreadyRecorded + } + } + return nil +} + +// MarkRecheckSubmissionSubmitted flips a (epoch, ticket, target) row from +// 'pending' to 'submitted'. Threading target_account is the C2 fix: +// without it, two pending rows for the same (epoch, ticket) would both +// be marked when only one was actually submitted. +func (s *SQLiteStore) MarkRecheckSubmissionSubmitted(ctx context.Context, epochID uint64, ticketID, targetAccount string) error { + _, err := s.db.ExecContext(ctx, `UPDATE storage_recheck_submissions SET status = 'submitted', submitted_at = ? WHERE epoch_id = ? AND ticket_id = ? AND target_account = ?`, time.Now().Unix(), epochID, ticketID, targetAccount) + return err +} + +// DeletePendingRecheckSubmission deletes a single (epoch, ticket, target) +// pending row after a hard tx failure — Wave 1 C2 fix. +func (s *SQLiteStore) DeletePendingRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM storage_recheck_submissions WHERE epoch_id = ? AND ticket_id = ? AND target_account = ? AND status = 'pending'`, epochID, ticketID, targetAccount) + return err +} + +func (s *SQLiteStore) RecordRecheckAttemptFailure(ctx context.Context, epochID uint64, ticketID, targetAccount string, err error, ttl time.Duration) error { + if epochID == 0 || ticketID == "" { + return fmt.Errorf("epoch_id and ticket_id are required") + } + msg := "" + if err != nil { + msg = err.Error() + } + expiresAt := time.Now().Add(ttl).Unix() + const stmt = `INSERT INTO recheck_attempt_failures (epoch_id, ticket_id, target_account, attempts, last_error, expires_at) +VALUES (?, ?, ?, 1, ?, ?) +ON CONFLICT(epoch_id, ticket_id, target_account) DO UPDATE SET attempts = attempts + 1, last_error = excluded.last_error, expires_at = excluded.expires_at` + _, execErr := s.db.ExecContext(ctx, stmt, epochID, ticketID, targetAccount, msg, expiresAt) + return execErr +} + +func (s *SQLiteStore) HasRecheckAttemptFailureBudgetExceeded(ctx context.Context, epochID uint64, ticketID, targetAccount string, maxAttempts int) (bool, error) { + if maxAttempts <= 0 { + return false, nil + } + const stmt = `SELECT attempts, expires_at FROM recheck_attempt_failures WHERE epoch_id = ? AND ticket_id = ? AND target_account = ? LIMIT 1` + var attempts int + var expiresAt int64 + err := s.db.QueryRowContext(ctx, stmt, epochID, ticketID, targetAccount).Scan(&attempts, &expiresAt) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + if expiresAt <= time.Now().Unix() { + _, _ = s.db.ExecContext(ctx, `DELETE FROM recheck_attempt_failures WHERE epoch_id = ? AND ticket_id = ? AND target_account = ?`, epochID, ticketID, targetAccount) + return false, nil + } + return attempts >= maxAttempts, nil +} + +func (s *SQLiteStore) PurgeExpiredRecheckAttemptFailures(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM recheck_attempt_failures WHERE expires_at <= ?`, time.Now().Unix()) + return err +} diff --git a/pkg/storage/queries/recheck_interface.go b/pkg/storage/queries/recheck_interface.go new file mode 100644 index 00000000..48b2bc49 --- /dev/null +++ b/pkg/storage/queries/recheck_interface.go @@ -0,0 +1,19 @@ +package queries + +import ( + "context" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +type RecheckQueries interface { + HasRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount string) (bool, error) + RecordPendingRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash string, resultClass audittypes.StorageProofResultClass) error + MarkRecheckSubmissionSubmitted(ctx context.Context, epochID uint64, ticketID, targetAccount string) error + DeletePendingRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount string) error + RecordRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash string, resultClass audittypes.StorageProofResultClass) error + RecordRecheckAttemptFailure(ctx context.Context, epochID uint64, ticketID, targetAccount string, err error, ttl time.Duration) error + HasRecheckAttemptFailureBudgetExceeded(ctx context.Context, epochID uint64, ticketID, targetAccount string, maxAttempts int) (bool, error) + PurgeExpiredRecheckAttemptFailures(ctx context.Context) error +} diff --git a/pkg/storage/queries/recheck_test.go b/pkg/storage/queries/recheck_test.go new file mode 100644 index 00000000..da6e8724 --- /dev/null +++ b/pkg/storage/queries/recheck_test.go @@ -0,0 +1,106 @@ +package queries + +import ( + "context" + "errors" + "testing" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRecheckSubmissionDedupPerTarget asserts the Wave 1 / C2 fix: chain +// dedup is per-(epoch, ticket, target_account), so two distinct targets +// within the same (epoch, ticket) must produce two persisted rows. Before +// Wave 1, the PK was (epoch, ticket) and the second target's row was +// silently dropped — masking that supernode from chain N/R/D math. +func TestRecheckSubmissionDedupPerTarget(t *testing.T) { + db := sqlx.MustConnect("sqlite3", ":memory:") + defer db.Close() + _, err := db.Exec(createStorageRecheckSubmissions) + require.NoError(t, err) + store := &SQLiteStore{db: db} + ctx := context.Background() + + // Initially nothing is recorded for either target. + exists, err := store.HasRecheckSubmission(ctx, 7, "ticket-1", "target-a") + require.NoError(t, err) + require.False(t, exists) + + // First target gets recorded. + require.NoError(t, store.RecordRecheckSubmission(ctx, 7, "ticket-1", "target-a", "orig", "rh1", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS)) + exists, err = store.HasRecheckSubmission(ctx, 7, "ticket-1", "target-a") + require.NoError(t, err) + require.True(t, exists) + + // Second target in the SAME (epoch, ticket) must also be recorded + // (this is the C2 fix — old behaviour silently dropped this row). + require.NoError(t, store.RecordRecheckSubmission(ctx, 7, "ticket-1", "target-b", "orig2", "rh2", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL)) + exists, err = store.HasRecheckSubmission(ctx, 7, "ticket-1", "target-b") + require.NoError(t, err) + require.True(t, exists) + + // Confirm both rows landed. + var n int + require.NoError(t, db.QueryRowContext(ctx, `SELECT COUNT(*) FROM storage_recheck_submissions WHERE epoch_id=? AND ticket_id=?`, 7, "ticket-1").Scan(&n)) + require.Equal(t, 2, n) + + // Same ticket in a different epoch is intentionally a different replay key. + exists, err = store.HasRecheckSubmission(ctx, 8, "ticket-1", "target-a") + require.NoError(t, err) + require.False(t, exists) + + // Idempotent second-call on the same (epoch, ticket, target) is a no-op + // (ON CONFLICT DO NOTHING) — preserves first row. + require.NoError(t, store.RecordRecheckSubmission(ctx, 7, "ticket-1", "target-a", "orig", "rh1-DIFFERENT", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS)) + var rh string + require.NoError(t, db.QueryRowContext(ctx, `SELECT recheck_transcript_hash FROM storage_recheck_submissions WHERE epoch_id=? AND ticket_id=? AND target_account=?`, 7, "ticket-1", "target-a").Scan(&rh)) + require.Equal(t, "rh1", rh) +} + +// TestRecordPendingRecheckSubmission_DuplicateReturnsTypedError covers the +// Wave 1 / L3 fix: duplicate-pending writes used to be silently swallowed +// by `INSERT OR IGNORE`; they now return ErrLEP6RecheckAlreadyRecorded so +// the attestor can branch on it. +func TestRecordPendingRecheckSubmission_DuplicateReturnsTypedError(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.RecordPendingRecheckSubmission(ctx, 7, "ticket-7", "target", "challenged", "actual", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL)) + + // Second pre-stage of the same triple → typed dedup error. + err := store.RecordPendingRecheckSubmission(ctx, 7, "ticket-7", "target", "challenged", "actual", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL) + require.Error(t, err) + require.True(t, errors.Is(err, ErrLEP6RecheckAlreadyRecorded), "want ErrLEP6RecheckAlreadyRecorded, got %v", err) + + // RecordRecheckSubmission (the historic non-typed path) stays + // idempotent (no error on duplicate) for back-compat. + require.NoError(t, store.RecordRecheckSubmission(ctx, 7, "ticket-7", "target", "x", "y", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS)) +} + +func TestRecheckPendingSubmittedAndFailureBudget(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.RecordPendingRecheckSubmission(ctx, 7, "ticket-7", "target", "challenged", "actual", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL)) + has, err := store.HasRecheckSubmission(ctx, 7, "ticket-7", "target") + require.NoError(t, err) + require.True(t, has) + require.NoError(t, store.MarkRecheckSubmissionSubmitted(ctx, 7, "ticket-7", "target")) + + blocked, err := store.HasRecheckAttemptFailureBudgetExceeded(ctx, 7, "ticket-7", "target", 2) + require.NoError(t, err) + require.False(t, blocked) + require.NoError(t, store.RecordRecheckAttemptFailure(ctx, 7, "ticket-7", "target", assert.AnError, time.Hour)) + blocked, err = store.HasRecheckAttemptFailureBudgetExceeded(ctx, 7, "ticket-7", "target", 2) + require.NoError(t, err) + require.False(t, blocked) + require.NoError(t, store.RecordRecheckAttemptFailure(ctx, 7, "ticket-7", "target", assert.AnError, time.Hour)) + blocked, err = store.HasRecheckAttemptFailureBudgetExceeded(ctx, 7, "ticket-7", "target", 2) + require.NoError(t, err) + require.True(t, blocked) +} diff --git a/pkg/storage/queries/self_healing_lep6.go b/pkg/storage/queries/self_healing_lep6.go new file mode 100644 index 00000000..a39e254a --- /dev/null +++ b/pkg/storage/queries/self_healing_lep6.go @@ -0,0 +1,294 @@ +package queries + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" +) + +// LEP6HealQueries — restart-safe dedup tables for the LEP-6 self-healing +// runtime. The LEP-6 dispatcher is chain-driven (poll heal-ops, role-decide +// from HealerSupernodeAccount / VerifierSupernodeAccounts), so a process +// restart that lost in-flight sync.Map entries could otherwise re-submit a +// claim or verification the chain has already accepted. Both tables are +// keyed so every (heal_op_id) or (heal_op_id, verifier) is permitted exactly +// once. +type LEP6HealQueries interface { + // RecordPendingHealClaim pre-stages a heal claim before chain submit. + RecordPendingHealClaim(ctx context.Context, healOpID uint64, ticketID, manifestHash, stagingDir string) error + // MarkHealClaimSubmitted flips a pending claim to submitted after chain ack. + MarkHealClaimSubmitted(ctx context.Context, healOpID uint64) error + // DeletePendingHealClaim deletes only a pending claim after hard tx failure. + DeletePendingHealClaim(ctx context.Context, healOpID uint64) error + // RecordHealClaim persists a submitted MsgClaimHealComplete for restart-time + // dedup. Returns ErrLEP6ClaimAlreadyRecorded if the row already exists. + RecordHealClaim(ctx context.Context, healOpID uint64, ticketID, manifestHash, stagingDir string) error + // HasHealClaim reports whether a SUBMITTED claim row exists for + // healOpID. Used by the dispatcher to skip resubmission on restart. + // Pending rows are excluded — see HasPendingHealClaim. + HasHealClaim(ctx context.Context, healOpID uint64) (bool, error) + // HasPendingHealClaim reports whether a pre-staged `pending` row exists + // for healOpID — a crash mid-submit left the row behind. Restart path + // uses this to drive a reconcile flow via GetHealOp. Wave 2 / C5 fix. + HasPendingHealClaim(ctx context.Context, healOpID uint64) (bool, error) + // GetHealClaim returns the persisted claim row (or sql.ErrNoRows). The + // finalizer reads staging_dir from this row when promoting a heal-op + // from HEALER_REPORTED to VERIFIED → publish. + GetHealClaim(ctx context.Context, healOpID uint64) (HealClaimRecord, error) + // ListHealClaims returns every persisted claim — used by the finalizer + // to enumerate staging entries on a fresh tick or after restart. + ListHealClaims(ctx context.Context) ([]HealClaimRecord, error) + // DeleteHealClaim removes the row after the finalizer has published or + // discarded the staging dir. + DeleteHealClaim(ctx context.Context, healOpID uint64) error + + // RecordPendingHealVerification pre-stages a verifier vote before chain submit. + RecordPendingHealVerification(ctx context.Context, healOpID uint64, verifierAccount string, verified bool, verificationHash string) error + // MarkHealVerificationSubmitted flips a pending vote to submitted after chain ack. + MarkHealVerificationSubmitted(ctx context.Context, healOpID uint64, verifierAccount string) error + // DeletePendingHealVerification deletes only a pending verifier row after hard tx failure. + DeletePendingHealVerification(ctx context.Context, healOpID uint64, verifierAccount string) error + // RecordHealVerification persists a submitted MsgSubmitHealVerification. + RecordHealVerification(ctx context.Context, healOpID uint64, verifierAccount string, verified bool, verificationHash string) error + // HasHealVerification reports whether a SUBMITTED row exists for the + // (heal_op_id, verifier_account) tuple. Verifier dispatch uses this + // to skip resubmission on restart; pending rows are excluded — see + // HasPendingHealVerification. + HasHealVerification(ctx context.Context, healOpID uint64, verifierAccount string) (bool, error) + // HasPendingHealVerification reports whether a pre-staged `pending` + // row exists for (heal_op_id, verifier_account). Wave 2 / C5 fix. + HasPendingHealVerification(ctx context.Context, healOpID uint64, verifierAccount string) (bool, error) +} + +// HealClaimRecord is the row shape for heal_claims_submitted. +type HealClaimRecord struct { + HealOpID uint64 + TicketID string + ManifestHash string + StagingDir string + SubmittedAt int64 + Status string +} + +// ErrLEP6ClaimAlreadyRecorded is returned by RecordHealClaim when the +// heal_op_id has already been persisted. +var ErrLEP6ClaimAlreadyRecorded = errors.New("lep6: heal claim already recorded") + +// ErrLEP6VerificationAlreadyRecorded is returned by RecordHealVerification +// when (heal_op_id, verifier_account) is already persisted. +var ErrLEP6VerificationAlreadyRecorded = errors.New("lep6: heal verification already recorded") + +const createHealClaimsSubmitted = ` +CREATE TABLE IF NOT EXISTS heal_claims_submitted ( + heal_op_id INTEGER PRIMARY KEY, + ticket_id TEXT NOT NULL, + manifest_hash TEXT NOT NULL, + staging_dir TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'submitted', + submitted_at INTEGER NOT NULL +);` + +const createHealClaimsStatusIndex = `CREATE INDEX IF NOT EXISTS idx_heal_claims_status ON heal_claims_submitted(status);` +const alterHealClaimsSubmittedStatus = `ALTER TABLE heal_claims_submitted ADD COLUMN status TEXT NOT NULL DEFAULT 'submitted';` + +const createHealVerificationsSubmitted = ` +CREATE TABLE IF NOT EXISTS heal_verifications_submitted ( + heal_op_id INTEGER NOT NULL, + verifier_account TEXT NOT NULL, + verified INTEGER NOT NULL, + verification_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'submitted', + submitted_at INTEGER NOT NULL, + PRIMARY KEY (heal_op_id, verifier_account) +);` + +const createHealVerificationsStatusIndex = `CREATE INDEX IF NOT EXISTS idx_heal_verifications_status ON heal_verifications_submitted(status);` +const alterHealVerificationsSubmittedStatus = `ALTER TABLE heal_verifications_submitted ADD COLUMN status TEXT NOT NULL DEFAULT 'submitted';` + +func (s *SQLiteStore) RecordPendingHealClaim(ctx context.Context, healOpID uint64, ticketID, manifestHash, stagingDir string) error { + return s.recordHealClaimWithStatus(ctx, healOpID, ticketID, manifestHash, stagingDir, "pending") +} + +// RecordHealClaim — see LEP6HealQueries.RecordHealClaim. +func (s *SQLiteStore) RecordHealClaim(ctx context.Context, healOpID uint64, ticketID, manifestHash, stagingDir string) error { + return s.recordHealClaimWithStatus(ctx, healOpID, ticketID, manifestHash, stagingDir, "submitted") +} + +func (s *SQLiteStore) recordHealClaimWithStatus(ctx context.Context, healOpID uint64, ticketID, manifestHash, stagingDir, status string) error { + const stmt = `INSERT INTO heal_claims_submitted (heal_op_id, ticket_id, manifest_hash, staging_dir, status, submitted_at) VALUES (?, ?, ?, ?, ?, ?)` + _, err := s.db.ExecContext(ctx, stmt, healOpID, ticketID, manifestHash, stagingDir, status, time.Now().Unix()) + if err != nil { + if isSQLiteUniqueViolation(err) { + return ErrLEP6ClaimAlreadyRecorded + } + return err + } + return nil +} + +func (s *SQLiteStore) MarkHealClaimSubmitted(ctx context.Context, healOpID uint64) error { + _, err := s.db.ExecContext(ctx, `UPDATE heal_claims_submitted SET status = 'submitted', submitted_at = ? WHERE heal_op_id = ?`, time.Now().Unix(), healOpID) + return err +} + +func (s *SQLiteStore) DeletePendingHealClaim(ctx context.Context, healOpID uint64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM heal_claims_submitted WHERE heal_op_id = ? AND status = 'pending'`, healOpID) + return err +} + +// HasHealClaim returns true only when a SUBMITTED claim row exists for +// healOpID. Pending rows from an interrupted submit are intentionally +// excluded so the dispatcher's restart path can detect them via +// HasPendingHealClaim and run the resume reconcile flow (Wave 2 / C5 fix). +// +// Before Wave 2 this returned true for any status, which caused a +// pending-row left over from a crash mid-submit to permanently block +// fresh dispatch — chain stayed SCHEDULED, finalizer never fired, +// heal-op silently expired and the supernode was penalized. +func (s *SQLiteStore) HasHealClaim(ctx context.Context, healOpID uint64) (bool, error) { + const stmt = `SELECT 1 FROM heal_claims_submitted WHERE heal_op_id = ? AND status = 'submitted' LIMIT 1` + var x int + err := s.db.QueryRowContext(ctx, stmt, healOpID).Scan(&x) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// HasPendingHealClaim reports whether a `pending` claim row exists for +// healOpID — meaning a previous tick pre-staged the row but did not yet +// confirm chain acceptance (or crashed between submit and persist). +// Restart-path callers use this to drive a reconcile via GetHealOp instead +// of either skipping the op forever or blindly resubmitting. +// +// Wave 2 / C5 fix. +func (s *SQLiteStore) HasPendingHealClaim(ctx context.Context, healOpID uint64) (bool, error) { + const stmt = `SELECT 1 FROM heal_claims_submitted WHERE heal_op_id = ? AND status = 'pending' LIMIT 1` + var x int + err := s.db.QueryRowContext(ctx, stmt, healOpID).Scan(&x) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// GetHealClaim — see LEP6HealQueries.GetHealClaim. +func (s *SQLiteStore) GetHealClaim(ctx context.Context, healOpID uint64) (HealClaimRecord, error) { + const stmt = `SELECT heal_op_id, ticket_id, manifest_hash, staging_dir, submitted_at, status FROM heal_claims_submitted WHERE heal_op_id = ?` + var r HealClaimRecord + err := s.db.QueryRowContext(ctx, stmt, healOpID).Scan(&r.HealOpID, &r.TicketID, &r.ManifestHash, &r.StagingDir, &r.SubmittedAt, &r.Status) + return r, err +} + +// ListHealClaims — see LEP6HealQueries.ListHealClaims. +func (s *SQLiteStore) ListHealClaims(ctx context.Context) ([]HealClaimRecord, error) { + const stmt = `SELECT heal_op_id, ticket_id, manifest_hash, staging_dir, submitted_at, status FROM heal_claims_submitted ORDER BY heal_op_id ASC` + rows, err := s.db.QueryContext(ctx, stmt) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]HealClaimRecord, 0) + for rows.Next() { + var r HealClaimRecord + if err := rows.Scan(&r.HealOpID, &r.TicketID, &r.ManifestHash, &r.StagingDir, &r.SubmittedAt, &r.Status); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// DeleteHealClaim — see LEP6HealQueries.DeleteHealClaim. +func (s *SQLiteStore) DeleteHealClaim(ctx context.Context, healOpID uint64) error { + const stmt = `DELETE FROM heal_claims_submitted WHERE heal_op_id = ?` + _, err := s.db.ExecContext(ctx, stmt, healOpID) + return err +} + +func (s *SQLiteStore) RecordPendingHealVerification(ctx context.Context, healOpID uint64, verifierAccount string, verified bool, verificationHash string) error { + return s.recordHealVerificationWithStatus(ctx, healOpID, verifierAccount, verified, verificationHash, "pending") +} + +// RecordHealVerification — see LEP6HealQueries.RecordHealVerification. +func (s *SQLiteStore) RecordHealVerification(ctx context.Context, healOpID uint64, verifierAccount string, verified bool, verificationHash string) error { + return s.recordHealVerificationWithStatus(ctx, healOpID, verifierAccount, verified, verificationHash, "submitted") +} + +func (s *SQLiteStore) recordHealVerificationWithStatus(ctx context.Context, healOpID uint64, verifierAccount string, verified bool, verificationHash, status string) error { + const stmt = `INSERT INTO heal_verifications_submitted (heal_op_id, verifier_account, verified, verification_hash, status, submitted_at) VALUES (?, ?, ?, ?, ?, ?)` + verifiedInt := 0 + if verified { + verifiedInt = 1 + } + _, err := s.db.ExecContext(ctx, stmt, healOpID, verifierAccount, verifiedInt, verificationHash, status, time.Now().Unix()) + if err != nil { + if isSQLiteUniqueViolation(err) { + return ErrLEP6VerificationAlreadyRecorded + } + return err + } + return nil +} + +func (s *SQLiteStore) MarkHealVerificationSubmitted(ctx context.Context, healOpID uint64, verifierAccount string) error { + _, err := s.db.ExecContext(ctx, `UPDATE heal_verifications_submitted SET status = 'submitted', submitted_at = ? WHERE heal_op_id = ? AND verifier_account = ?`, time.Now().Unix(), healOpID, verifierAccount) + return err +} + +func (s *SQLiteStore) DeletePendingHealVerification(ctx context.Context, healOpID uint64, verifierAccount string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM heal_verifications_submitted WHERE heal_op_id = ? AND verifier_account = ? AND status = 'pending'`, healOpID, verifierAccount) + return err +} + +// HasHealVerification reports whether a SUBMITTED verifier row exists for +// (healOpID, verifierAccount). Pending rows from an interrupted submit are +// excluded — Wave 2 / C5 fix mirroring HasHealClaim. +func (s *SQLiteStore) HasHealVerification(ctx context.Context, healOpID uint64, verifierAccount string) (bool, error) { + const stmt = `SELECT 1 FROM heal_verifications_submitted WHERE heal_op_id = ? AND verifier_account = ? AND status = 'submitted' LIMIT 1` + var x int + err := s.db.QueryRowContext(ctx, stmt, healOpID, verifierAccount).Scan(&x) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// HasPendingHealVerification reports whether a `pending` verifier row +// exists for (healOpID, verifierAccount) — the verifier counterpart to +// HasPendingHealClaim. Wave 2 / C5 fix. +func (s *SQLiteStore) HasPendingHealVerification(ctx context.Context, healOpID uint64, verifierAccount string) (bool, error) { + const stmt = `SELECT 1 FROM heal_verifications_submitted WHERE heal_op_id = ? AND verifier_account = ? AND status = 'pending' LIMIT 1` + var x int + err := s.db.QueryRowContext(ctx, stmt, healOpID, verifierAccount).Scan(&x) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// isSQLiteUniqueViolation matches both the sqlite3 driver's typed error and +// the textual surface ("UNIQUE constraint failed") so the dedup helpers stay +// portable against driver changes. +func isSQLiteUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || + strings.Contains(msg, "PRIMARY KEY must be unique") +} diff --git a/pkg/storage/queries/self_healing_lep6_test.go b/pkg/storage/queries/self_healing_lep6_test.go new file mode 100644 index 00000000..3899b1da --- /dev/null +++ b/pkg/storage/queries/self_healing_lep6_test.go @@ -0,0 +1,142 @@ +package queries + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/require" +) + +func newTestStore(t *testing.T) *SQLiteStore { + t.Helper() + dbFile := filepath.Join(t.TempDir(), "history.db") + db, err := sqlx.Connect("sqlite3", dbFile) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + for _, stmt := range []string{createHealClaimsSubmitted, createHealVerificationsSubmitted, createStorageRecheckSubmissions, createRecheckAttemptFailures, createRecheckAttemptFailuresExpiresIndex} { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("exec migration: %v", err) + } + } + return &SQLiteStore{db: db} +} + +func TestLEP6_HealClaim_RoundTripAndDedup(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + if has, err := s.HasHealClaim(ctx, 42); err != nil || has { + t.Fatalf("HasHealClaim before insert: has=%v err=%v", has, err) + } + if err := s.RecordHealClaim(ctx, 42, "ticket-x", "manifest-h", "/tmp/staging/42"); err != nil { + t.Fatalf("RecordHealClaim: %v", err) + } + // Restart-safety: second insert must be rejected with the typed error. + err := s.RecordHealClaim(ctx, 42, "ticket-x", "manifest-h", "/tmp/staging/42") + if !errors.Is(err, ErrLEP6ClaimAlreadyRecorded) { + t.Fatalf("expected ErrLEP6ClaimAlreadyRecorded on duplicate, got %v", err) + } + if has, err := s.HasHealClaim(ctx, 42); err != nil || !has { + t.Fatalf("HasHealClaim after insert: has=%v err=%v", has, err) + } + rec, err := s.GetHealClaim(ctx, 42) + if err != nil { + t.Fatalf("GetHealClaim: %v", err) + } + if rec.HealOpID != 42 || rec.TicketID != "ticket-x" || rec.ManifestHash != "manifest-h" || rec.StagingDir != "/tmp/staging/42" { + t.Fatalf("GetHealClaim mismatch: %+v", rec) + } + all, err := s.ListHealClaims(ctx) + if err != nil || len(all) != 1 { + t.Fatalf("ListHealClaims: %v %d", err, len(all)) + } + if err := s.DeleteHealClaim(ctx, 42); err != nil { + t.Fatalf("DeleteHealClaim: %v", err) + } + if has, err := s.HasHealClaim(ctx, 42); err != nil || has { + t.Fatalf("HasHealClaim after delete: has=%v err=%v", has, err) + } +} + +func TestLEP6_HealVerification_PerVerifierDedup(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + if err := s.RecordHealVerification(ctx, 7, "sn-a", true, "hash-a"); err != nil { + t.Fatalf("record A: %v", err) + } + // Same heal_op, different verifier — must succeed. + if err := s.RecordHealVerification(ctx, 7, "sn-b", false, "hash-b"); err != nil { + t.Fatalf("record B: %v", err) + } + // Same (op, verifier) — must dedup. + err := s.RecordHealVerification(ctx, 7, "sn-a", true, "hash-a") + if !errors.Is(err, ErrLEP6VerificationAlreadyRecorded) { + t.Fatalf("expected dedup error, got %v", err) + } + if has, err := s.HasHealVerification(ctx, 7, "sn-a"); err != nil || !has { + t.Fatalf("HasHealVerification(sn-a): has=%v err=%v", has, err) + } + if has, err := s.HasHealVerification(ctx, 7, "sn-c"); err != nil || has { + t.Fatalf("HasHealVerification(sn-c) should be false: has=%v err=%v", has, err) + } +} + +func TestLEP6HealClaimPendingLifecycle(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.RecordPendingHealClaim(ctx, 101, "ticket-101", "manifest", "/tmp/stage")) + // Wave 2 / C5 fix: HasHealClaim returns SUBMITTED-only. A pending row + // must not block fresh dispatch. + has, err := store.HasHealClaim(ctx, 101) + require.NoError(t, err) + require.False(t, has, "pending row must NOT count as submitted (C5)") + pending, err := store.HasPendingHealClaim(ctx, 101) + require.NoError(t, err) + require.True(t, pending, "pending row must surface via HasPendingHealClaim") + + err = store.RecordPendingHealClaim(ctx, 101, "ticket-101", "manifest", "/tmp/stage") + require.ErrorIs(t, err, ErrLEP6ClaimAlreadyRecorded) + + require.NoError(t, store.MarkHealClaimSubmitted(ctx, 101)) + // After Mark, only HasHealClaim returns true. + has, err = store.HasHealClaim(ctx, 101) + require.NoError(t, err) + require.True(t, has) + pending, err = store.HasPendingHealClaim(ctx, 101) + require.NoError(t, err) + require.False(t, pending, "after Mark, no pending row should remain") + claims, err := store.ListHealClaims(ctx) + require.NoError(t, err) + require.Len(t, claims, 1) + require.Equal(t, uint64(101), claims[0].HealOpID) +} + +func TestLEP6HealVerificationPendingLifecycle(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.RecordPendingHealVerification(ctx, 202, "verifier-a", true, "hash")) + // Wave 2 / C5 fix: pending must NOT count as submitted. + has, err := store.HasHealVerification(ctx, 202, "verifier-a") + require.NoError(t, err) + require.False(t, has, "pending row must NOT count as submitted (C5)") + pending, err := store.HasPendingHealVerification(ctx, 202, "verifier-a") + require.NoError(t, err) + require.True(t, pending) + + err = store.RecordPendingHealVerification(ctx, 202, "verifier-a", true, "hash") + require.ErrorIs(t, err, ErrLEP6VerificationAlreadyRecorded) + + require.NoError(t, store.MarkHealVerificationSubmitted(ctx, 202, "verifier-a")) + has, err = store.HasHealVerification(ctx, 202, "verifier-a") + require.NoError(t, err) + require.True(t, has) +} diff --git a/pkg/storage/queries/sqlite.go b/pkg/storage/queries/sqlite.go index dfdd90cd..f60a78e9 100644 --- a/pkg/storage/queries/sqlite.go +++ b/pkg/storage/queries/sqlite.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" "github.com/jmoiron/sqlx" @@ -292,21 +293,27 @@ func (s *SQLiteStore) CloseHistoryDB(ctx context.Context) { } } -// OpenHistoryDB opens history DB +// OpenHistoryDB opens history DB in the default supernode home. func OpenHistoryDB() (LocalStoreInterface, error) { - // Always use ~/.supernode as the base directory homeDir, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("cannot get user home directory: %w", err) } - historyBasePath := filepath.Join(homeDir, ".supernode") + return OpenHistoryDBAt(filepath.Join(homeDir, ".supernode")) +} + +// OpenHistoryDBAt opens history DB under baseDir. +func OpenHistoryDBAt(baseDir string) (LocalStoreInterface, error) { + if strings.TrimSpace(baseDir) == "" { + return nil, fmt.Errorf("history db base directory is required") + } - // Ensure the base directory exists before opening the DB - if err := os.MkdirAll(historyBasePath, 0o755); err != nil { - return nil, fmt.Errorf("cannot create history db directory %q: %w", historyBasePath, err) + // Ensure the base directory exists before opening the DB. + if err := os.MkdirAll(baseDir, 0o755); err != nil { + return nil, fmt.Errorf("cannot create history db directory %q: %w", baseDir, err) } - dbFile := filepath.Join(historyBasePath, historyDBName) + dbFile := filepath.Join(baseDir, historyDBName) db, err := sqlx.Connect("sqlite3", dbFile) if err != nil { return nil, fmt.Errorf("cannot open sqlite database: %w", err) @@ -388,6 +395,55 @@ func OpenHistoryDB() (LocalStoreInterface, error) { return nil, fmt.Errorf("cannot create table(s): %w", err) } + if _, err := db.Exec(createHealClaimsSubmitted); err != nil { + return nil, fmt.Errorf("cannot create heal_claims_submitted: %w", err) + } + if err := addColumnIfMissing(context.Background(), db, "heal_claims_submitted", "status", + alterHealClaimsSubmittedStatus); err != nil { + return nil, fmt.Errorf("migrate heal_claims_submitted.status: %w", err) + } + if _, err := db.Exec(createHealClaimsStatusIndex); err != nil { + return nil, fmt.Errorf("cannot create heal_claims_submitted status index: %w", err) + } + + if _, err := db.Exec(createHealVerificationsSubmitted); err != nil { + return nil, fmt.Errorf("cannot create heal_verifications_submitted: %w", err) + } + if err := addColumnIfMissing(context.Background(), db, "heal_verifications_submitted", "status", + alterHealVerificationsSubmittedStatus); err != nil { + return nil, fmt.Errorf("migrate heal_verifications_submitted.status: %w", err) + } + if _, err := db.Exec(createHealVerificationsStatusIndex); err != nil { + return nil, fmt.Errorf("cannot create heal_verifications_submitted status index: %w", err) + } + + if _, err := db.Exec(createStorageRecheckSubmissions); err != nil { + return nil, fmt.Errorf("cannot create storage_recheck_submissions: %w", err) + } + if err := addColumnIfMissing(context.Background(), db, "storage_recheck_submissions", "status", + alterStorageRecheckSubmissionStatus); err != nil { + return nil, fmt.Errorf("migrate storage_recheck_submissions.status: %w", err) + } + if err := migrateStorageRecheckSubmissionsPK(context.Background(), db); err != nil { + return nil, fmt.Errorf("migrate storage_recheck_submissions PK: %w", err) + } + if _, err := db.Exec(createStorageRecheckSubmissionStatusIndex); err != nil { + return nil, fmt.Errorf("cannot create storage_recheck_submissions status index: %w", err) + } + if _, err := db.Exec(createRecheckAttemptFailures); err != nil { + return nil, fmt.Errorf("cannot create recheck_attempt_failures: %w", err) + } + if err := migrateRecheckAttemptFailuresPK(context.Background(), db); err != nil { + return nil, fmt.Errorf("migrate recheck_attempt_failures PK: %w", err) + } + if _, err := db.Exec(createRecheckAttemptFailuresExpiresIndex); err != nil { + return nil, fmt.Errorf("cannot create recheck_attempt_failures expires index: %w", err) + } + + if _, err := db.Exec(createStorageChallengeStateTable); err != nil { + return nil, fmt.Errorf("cannot create storage_challenge_state: %w", err) + } + _, _ = db.Exec(alterTaskHistory) _, _ = db.Exec(alterTablePingHistory) diff --git a/pkg/storage/queries/sqlite_open_test.go b/pkg/storage/queries/sqlite_open_test.go new file mode 100644 index 00000000..77aaae14 --- /dev/null +++ b/pkg/storage/queries/sqlite_open_test.go @@ -0,0 +1,27 @@ +package queries + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestOpenHistoryDBAtUsesConfiguredBaseDir(t *testing.T) { + baseDir := t.TempDir() + store, err := OpenHistoryDBAt(baseDir) + if err != nil { + t.Fatalf("OpenHistoryDBAt: %v", err) + } + t.Cleanup(func() { store.CloseHistoryDB(context.Background()) }) + + if _, err := os.Stat(filepath.Join(baseDir, historyDBName)); err != nil { + t.Fatalf("expected history db under configured base dir: %v", err) + } +} + +func TestOpenHistoryDBAtRejectsEmptyBaseDir(t *testing.T) { + if _, err := OpenHistoryDBAt(" "); err == nil { + t.Fatal("expected error for empty base dir") + } +} diff --git a/pkg/storage/queries/sqlite_schema_helpers.go b/pkg/storage/queries/sqlite_schema_helpers.go new file mode 100644 index 00000000..b8263930 --- /dev/null +++ b/pkg/storage/queries/sqlite_schema_helpers.go @@ -0,0 +1,121 @@ +package queries + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// columnExists reports whether the SQLite table has a column with the given +// name. Used to make `ALTER TABLE … ADD COLUMN` idempotent without relying +// on swallowing errors (which previously masked real failures like locked +// DB / disk full — Wave 1 fix for M8). +func columnExists(ctx context.Context, db sqliteExecQuerier, table, column string) (bool, error) { + rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", quoteSQLiteIdent(table))) + if err != nil { + return false, fmt.Errorf("pragma table_info(%s): %w", table, err) + } + defer rows.Close() + for rows.Next() { + var ( + cid int + name string + ctype sql.NullString + notnull int + dflt sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return false, fmt.Errorf("scan table_info(%s): %w", table, err) + } + if strings.EqualFold(name, column) { + return true, nil + } + } + if err := rows.Err(); err != nil { + return false, fmt.Errorf("iterate table_info(%s): %w", table, err) + } + return false, nil +} + +// primaryKeyColumns returns the ordered set of PRIMARY KEY columns for a +// SQLite table, lower-cased. Used to detect a stale single-column PK on +// `storage_recheck_submissions` so we can migrate it to the multi-column +// PK without relying on schema text matching. +func primaryKeyColumns(ctx context.Context, db sqliteExecQuerier, table string) ([]string, error) { + rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", quoteSQLiteIdent(table))) + if err != nil { + return nil, fmt.Errorf("pragma table_info(%s): %w", table, err) + } + defer rows.Close() + type pkEntry struct { + name string + order int + } + var entries []pkEntry + for rows.Next() { + var ( + cid int + name string + ctype sql.NullString + notnull int + dflt sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return nil, fmt.Errorf("scan table_info(%s): %w", table, err) + } + if pk > 0 { + entries = append(entries, pkEntry{name: strings.ToLower(name), order: pk}) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate table_info(%s): %w", table, err) + } + // PRAGMA returns pk-ordinal in the `pk` column; sort by that ordinal. + for i := 0; i < len(entries); i++ { + for j := i + 1; j < len(entries); j++ { + if entries[j].order < entries[i].order { + entries[i], entries[j] = entries[j], entries[i] + } + } + } + cols := make([]string, len(entries)) + for i, e := range entries { + cols[i] = e.name + } + return cols, nil +} + +// addColumnIfMissing runs `ALTER TABLE ADD COLUMN …` only when the +// column is absent. Real errors (locked DB, disk full, malformed SQL) are +// propagated rather than silently swallowed — Wave 1 fix for M8. +func addColumnIfMissing(ctx context.Context, db sqliteExecQuerier, table, column, addColumnSQL string) error { + exists, err := columnExists(ctx, db, table, column) + if err != nil { + return err + } + if exists { + return nil + } + if _, err := db.ExecContext(ctx, addColumnSQL); err != nil { + return fmt.Errorf("alter table %s add column %s: %w", table, column, err) + } + return nil +} + +// sqliteExecQuerier is the minimal subset of *sql.DB / *sqlx.DB that the +// schema helpers need. Decoupled so we can reuse them inside transactions. +type sqliteExecQuerier interface { + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) +} + +// quoteSQLiteIdent returns the identifier wrapped in double-quotes with any +// embedded double-quote escaped, so `PRAGMA table_info("…")` is safe even if +// future tables use reserved words. Only the fixed table name set in this +// package flows through here, but the safety is cheap. +func quoteSQLiteIdent(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` +} diff --git a/pkg/storage/queries/storage_challenge_state.go b/pkg/storage/queries/storage_challenge_state.go new file mode 100644 index 00000000..6fc39f2f --- /dev/null +++ b/pkg/storage/queries/storage_challenge_state.go @@ -0,0 +1,79 @@ +package queries + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// LEP-6 review M9 (Matee, 2026-05-06): persist `lastSubmittedEpoch` to SQLite +// so that a supernode restart does not replay storage-challenge dispatch for +// the most-recently-submitted epoch. Previously this was an in-memory variable +// in `supernode/storage_challenge/service.go` (`lastRunEpoch`); after a crash +// the process would re-dispatch and re-submit the same epoch on the very next +// tick, doubling the keyring spend and burning observer/host-reporter time. +// +// Storage shape: a tiny single-row key-value table `storage_challenge_state` +// keyed by an arbitrary `state_key` string so the same table can hold any +// future per-service scalar without a schema migration. The first key we use +// is `lep6.last_submitted_epoch`. Reads return (0, false, nil) on a fresh DB. + +const createStorageChallengeStateTable = ` +CREATE TABLE IF NOT EXISTS storage_challenge_state ( + state_key TEXT PRIMARY KEY NOT NULL, + epoch_id INTEGER NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +` + +// LEP6LastSubmittedEpochKey is the storage_challenge_state row key used by +// the storage-challenge dispatcher to persist the last successfully-dispatched +// epoch. Exported so callers can build per-service variants if/when needed. +const LEP6LastSubmittedEpochKey = "lep6.last_submitted_epoch" + +// StorageChallengeStateQueries persists supernode-side scalar state that must +// survive process restarts (e.g. lastSubmittedEpoch). +type StorageChallengeStateQueries interface { + // GetStorageChallengeState returns (epoch, true, nil) if the row exists, + // or (0, false, nil) if there is no row yet for the key. Returns a + // non-nil error only on storage faults. + GetStorageChallengeState(ctx context.Context, key string) (uint64, bool, error) + + // SetStorageChallengeState upserts the row to (key, epoch). Idempotent. + SetStorageChallengeState(ctx context.Context, key string, epoch uint64) error +} + +// GetStorageChallengeState — see interface comment. +func (s *SQLiteStore) GetStorageChallengeState(ctx context.Context, key string) (uint64, bool, error) { + if s == nil || s.db == nil { + return 0, false, errors.New("sqlite store is nil") + } + var epoch uint64 + row := s.db.QueryRowContext(ctx, + `SELECT epoch_id FROM storage_challenge_state WHERE state_key = ? LIMIT 1`, key) + if err := row.Scan(&epoch); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + return 0, false, fmt.Errorf("query storage_challenge_state(%q): %w", key, err) + } + return epoch, true, nil +} + +// SetStorageChallengeState — see interface comment. +func (s *SQLiteStore) SetStorageChallengeState(ctx context.Context, key string, epoch uint64) error { + if s == nil || s.db == nil { + return errors.New("sqlite store is nil") + } + if _, err := s.db.ExecContext(ctx, ` + INSERT INTO storage_challenge_state (state_key, epoch_id, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(state_key) DO UPDATE SET + epoch_id = excluded.epoch_id, + updated_at = CURRENT_TIMESTAMP + `, key, epoch); err != nil { + return fmt.Errorf("upsert storage_challenge_state(%q,%d): %w", key, epoch, err) + } + return nil +} diff --git a/pkg/storage/queries/wave1_schema_test.go b/pkg/storage/queries/wave1_schema_test.go new file mode 100644 index 00000000..ccbcd40b --- /dev/null +++ b/pkg/storage/queries/wave1_schema_test.go @@ -0,0 +1,156 @@ +package queries + +import ( + "context" + "fmt" + "testing" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/require" +) + +// TestColumnExists exercises the M8 helper directly. +func TestColumnExists(t *testing.T) { + db := sqlx.MustConnect("sqlite3", ":memory:") + defer db.Close() + _, err := db.Exec(`CREATE TABLE t1 (a INTEGER, b TEXT);`) + require.NoError(t, err) + ctx := context.Background() + + exists, err := columnExists(ctx, db, "t1", "a") + require.NoError(t, err) + require.True(t, exists) + exists, err = columnExists(ctx, db, "t1", "B") // case-insensitive + require.NoError(t, err) + require.True(t, exists) + exists, err = columnExists(ctx, db, "t1", "missing") + require.NoError(t, err) + require.False(t, exists) +} + +// TestAddColumnIfMissing_Idempotent covers the M8 fix: ALTER TABLE +// ADD COLUMN runs once on fresh DBs (already has column → no-op), +// once on legacy DBs (column added), and is then idempotent on subsequent +// startups. Real ALTER errors propagate (no silent swallow). +func TestAddColumnIfMissing_Idempotent(t *testing.T) { + db := sqlx.MustConnect("sqlite3", ":memory:") + defer db.Close() + _, err := db.Exec(`CREATE TABLE t (a INTEGER);`) // legacy shape — missing 'extra' + require.NoError(t, err) + ctx := context.Background() + + // First call adds the column. + require.NoError(t, addColumnIfMissing(ctx, db, "t", "extra", `ALTER TABLE t ADD COLUMN extra TEXT NOT NULL DEFAULT 'x';`)) + exists, err := columnExists(ctx, db, "t", "extra") + require.NoError(t, err) + require.True(t, exists) + + // Second call must be a no-op (does NOT re-issue the ALTER, which + // would error with "duplicate column name"). + require.NoError(t, addColumnIfMissing(ctx, db, "t", "extra", `ALTER TABLE t ADD COLUMN extra TEXT NOT NULL DEFAULT 'x';`)) +} + +// TestMigrateStorageRecheckSubmissionsPK covers the C2 migration: an old +// DB with PK (epoch_id, ticket_id) is migrated to PK (epoch_id, ticket_id, +// target_account) preserving all data. Idempotent on already-migrated DBs. +func TestMigrateStorageRecheckSubmissionsPK(t *testing.T) { + db := sqlx.MustConnect("sqlite3", ":memory:") + defer db.Close() + ctx := context.Background() + + // Seed the OLD schema (pre-Wave-1 PK). + const oldSchema = ` +CREATE TABLE storage_recheck_submissions ( + epoch_id INTEGER NOT NULL, + ticket_id TEXT NOT NULL, + target_account TEXT NOT NULL, + challenged_transcript_hash TEXT NOT NULL, + recheck_transcript_hash TEXT NOT NULL, + result_class INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'submitted', + submitted_at INTEGER NOT NULL, + PRIMARY KEY (epoch_id, ticket_id) +);` + _, err := db.Exec(oldSchema) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO storage_recheck_submissions VALUES (7, 'ticket-1', 'target-a', 'ch', 'rh', 1, 'submitted', 1234);`) + require.NoError(t, err) + + // Confirm pre-migration PK shape. + pk, err := primaryKeyColumns(ctx, db, "storage_recheck_submissions") + require.NoError(t, err) + require.Equal(t, []string{"epoch_id", "ticket_id"}, pk) + + // Run migration. + require.NoError(t, migrateStorageRecheckSubmissionsPK(ctx, db)) + + // Verify new PK shape and preserved data. + pk, err = primaryKeyColumns(ctx, db, "storage_recheck_submissions") + require.NoError(t, err) + require.Equal(t, []string{"epoch_id", "ticket_id", "target_account"}, pk) + var n int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM storage_recheck_submissions WHERE epoch_id=7 AND ticket_id='ticket-1' AND target_account='target-a'`).Scan(&n)) + require.Equal(t, 1, n) + + // Idempotency: second run is a no-op. + require.NoError(t, migrateStorageRecheckSubmissionsPK(ctx, db)) + + // Multi-target now allowed under the new PK. + store := &SQLiteStore{db: db} + require.NoError(t, store.RecordRecheckSubmission(ctx, 7, "ticket-1", "target-b", "ch2", "rh2", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS)) + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM storage_recheck_submissions WHERE epoch_id=7 AND ticket_id='ticket-1'`).Scan(&n)) + require.Equal(t, 2, n) +} + +// TestMigrateStorageRecheckSubmissionsPK_AlreadyMigratedNoOp covers the +// idempotent fast-path where a fresh DB created via createStorageRecheckSubmissions +// already has the multi-column PK. +func TestMigrateStorageRecheckSubmissionsPK_AlreadyMigratedNoOp(t *testing.T) { + db := sqlx.MustConnect("sqlite3", ":memory:") + defer db.Close() + _, err := db.Exec(createStorageRecheckSubmissions) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, migrateStorageRecheckSubmissionsPK(ctx, db)) + pk, err := primaryKeyColumns(ctx, db, "storage_recheck_submissions") + require.NoError(t, err) + require.Equal(t, []string{"epoch_id", "ticket_id", "target_account"}, pk) +} + +func TestMigrateRecheckAttemptFailuresPK(t *testing.T) { + ctx := context.Background() + db, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + defer db.Close() + + _, err = db.Exec(` +CREATE TABLE recheck_attempt_failures ( + epoch_id INTEGER NOT NULL, + ticket_id TEXT NOT NULL, + target_account TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 1, + last_error TEXT, + expires_at INTEGER NOT NULL, + PRIMARY KEY (epoch_id, ticket_id) +);`) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO recheck_attempt_failures VALUES (7, 'ticket-1', 'target-a', 1, 'boom', 999999);`) + require.NoError(t, err) + + require.NoError(t, migrateRecheckAttemptFailuresPK(ctx, db)) + pk, err := primaryKeyColumns(ctx, db, "recheck_attempt_failures") + require.NoError(t, err) + require.Equal(t, []string{"epoch_id", "ticket_id", "target_account"}, pk) + + store := &SQLiteStore{db: db} + require.NoError(t, store.RecordRecheckAttemptFailure(ctx, 7, "ticket-1", "target-b", fmt.Errorf("nope"), time.Hour)) + blockedA, err := store.HasRecheckAttemptFailureBudgetExceeded(ctx, 7, "ticket-1", "target-a", 2) + require.NoError(t, err) + require.False(t, blockedA) + blockedB, err := store.HasRecheckAttemptFailureBudgetExceeded(ctx, 7, "ticket-1", "target-b", 2) + require.NoError(t, err) + require.False(t, blockedB) +} diff --git a/pkg/storage/queries/wave3_state_test.go b/pkg/storage/queries/wave3_state_test.go new file mode 100644 index 00000000..4c682dc9 --- /dev/null +++ b/pkg/storage/queries/wave3_state_test.go @@ -0,0 +1,65 @@ +package queries + +import ( + "context" + "testing" +) + +// TestStorageChallengeState_M9_RoundTripPersistsAcrossOpen pins LEP-6 review +// M9 (Matee, 2026-05-06): the storage-challenge dispatcher's last-submitted +// epoch must survive a process restart so we don't re-dispatch the most +// recent epoch on startup. Round-trip through OpenHistoryDBAt twice. +func TestStorageChallengeState_M9_RoundTripPersistsAcrossOpen(t *testing.T) { + baseDir := t.TempDir() + ctx := context.Background() + + // First open: write the persisted epoch. + store1, err := OpenHistoryDBAt(baseDir) + if err != nil { + t.Fatalf("OpenHistoryDBAt initial: %v", err) + } + + // Fresh DB → no row. + if _, ok, err := store1.GetStorageChallengeState(ctx, LEP6LastSubmittedEpochKey); err != nil { + t.Fatalf("GetStorageChallengeState fresh: %v", err) + } else if ok { + t.Fatalf("fresh DB must have no persisted last-epoch row") + } + + const persistedEpoch uint64 = 4242 + if err := store1.SetStorageChallengeState(ctx, LEP6LastSubmittedEpochKey, persistedEpoch); err != nil { + t.Fatalf("SetStorageChallengeState: %v", err) + } + store1.CloseHistoryDB(ctx) + + // Second open: read back must succeed with the persisted value. + store2, err := OpenHistoryDBAt(baseDir) + if err != nil { + t.Fatalf("OpenHistoryDBAt reopen: %v", err) + } + defer store2.CloseHistoryDB(ctx) + + got, ok, err := store2.GetStorageChallengeState(ctx, LEP6LastSubmittedEpochKey) + if err != nil { + t.Fatalf("GetStorageChallengeState reopen: %v", err) + } + if !ok { + t.Fatalf("M9 regression: persisted last-epoch row missing after reopen") + } + if got != persistedEpoch { + t.Fatalf("M9 regression: persisted epoch mismatch: got %d, want %d", got, persistedEpoch) + } + + // Idempotent upsert: writing the same value again must not error. + if err := store2.SetStorageChallengeState(ctx, LEP6LastSubmittedEpochKey, persistedEpoch); err != nil { + t.Fatalf("idempotent upsert: %v", err) + } + // Updating to a higher value works. + if err := store2.SetStorageChallengeState(ctx, LEP6LastSubmittedEpochKey, persistedEpoch+1); err != nil { + t.Fatalf("update upsert: %v", err) + } + got2, _, _ := store2.GetStorageChallengeState(ctx, LEP6LastSubmittedEpochKey) + if got2 != persistedEpoch+1 { + t.Fatalf("update did not stick: got %d, want %d", got2, persistedEpoch+1) + } +} diff --git a/pkg/storagechallenge/deterministic/lep6.go b/pkg/storagechallenge/deterministic/lep6.go new file mode 100644 index 00000000..85f1753c --- /dev/null +++ b/pkg/storagechallenge/deterministic/lep6.go @@ -0,0 +1,748 @@ +// LEP-6 deterministic primitives. +// +// This file implements the off-chain computation library that the supernode's +// storage_challenge runtime, recheck service, and self-healing dispatcher all +// share. Every function in this file is pure: same inputs always yield the same +// output, no I/O, no goroutines, no clock. +// +// # Why "deterministic" matters +// +// LEP-6 distributes one compound storage challenge per epoch to a deterministic +// 1/3 subset of active supernodes. Multiple independent reporters (probers) +// must agree byte-for-byte on: +// +// - which supernodes are challenged this epoch (target set), +// - which (challenger, target) pair an individual reporter is assigned to, +// - which ticket is selected per bucket, +// - which artifact (class, ordinal, key) is challenged, +// - which byte ranges are sampled, +// - the resulting transcript identifiers. +// +// If any one of those steps diverges between two supernodes, their +// StorageProofResults will not match and the chain will treat them as +// contradictions — penalising both. This package is therefore the single +// canonical implementation that every supernode must run. +// +// # Chain-mirrored vs supernode-canonical primitives +// +// Two primitives MUST mirror the chain byte-for-byte because the chain itself +// runs them to compute the assignment for `MsgSubmitEpochReport` validation: +// +// - SelectLEP6Targets — mirrors lumera/x/audit/v1/keeper/audit_peer_assignment.go +// (rankStorageTruthAccounts, label "challenge_target") +// - PairChallengerToTarget — mirrors the inline pair-ranking loop in the +// same file (label "pair") +// +// Both use SHA-256 with the byte composition documented on +// storageTruthAssignmentHash below — exactly matching +// lumera/x/audit/v1/keeper/audit_peer_assignment.go:232. +// +// All other primitives (ticket / class / ordinal selection, multi-range +// offsets, compound hash, derivation input hash, transcript hash) are NOT +// computed on the chain. The chain stores their outputs as opaque strings and +// only validates structural fields (non-empty, ordinal < count, class ∈ +// {INDEX, SYMBOL}, etc.). The supernode is therefore the canonical source for +// these encodings. To keep all reporters in lockstep, every supernode must use +// the byte schema defined here. Changes to these schemas are protocol-level +// changes that require coordination across the network — do not adjust them +// without bumping a versioned domain separator. +// +// # Reference test vectors +// +// - audit_peer_assignment_test.go::TestStorageTruthAssignmentUsesOneThirdCoverage +// uses seed=[]byte("01234567890123456789012345678901") with active set +// {sn-a..sn-f} and divisor=3, expecting targetCount=2. Reproduced as +// TestSelectLEP6Targets_OneThirdCoverage_AssignmentMatchesChain. +package deterministic + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "math" + "sort" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "lukechampine.com/blake3" +) + +// LEP6 default constants. +// +// Mirrors lumera/x/audit/v1/types/params.go defaults; duplicated here so the +// supernode can compute primitives without round-tripping to the chain when +// the operator has not overridden the relevant params. +const ( + // LEP6CompoundRangesPerArtifact is k in LEP-6 §11. + LEP6CompoundRangesPerArtifact = 4 + // LEP6CompoundRangeLenBytes is range_len in LEP-6 §11. + LEP6CompoundRangeLenBytes = 256 + // LEP6ChallengeTargetDivisor selects 1/3 of active supernodes per epoch. + LEP6ChallengeTargetDivisor = 3 + // LEP6ArtifactClassRollModulus is the divisor for the §10 class roll + // (0..1 -> INDEX, 2..9 -> SYMBOL). + LEP6ArtifactClassRollModulus = 10 + + // MaxCompoundRanges caps the number of ranges a single GetCompoundProof + // call may request. The LEP-6 §11 spec value is k=4; we accept up to 4x + // that to leave headroom for chain-param tweaks while bounding DoS amplification. + MaxCompoundRanges = 16 + // MaxCompoundRangeLenBytes caps the per-range length on the recipient + // side. Spec is 256 bytes; cap is 4x to bound DoS amplification while + // tolerating chain-param drift. + MaxCompoundRangeLenBytes = 4 * LEP6CompoundRangeLenBytes + // MaxCompoundAggregateBytes caps the total bytes any single + // GetCompoundProof response may serve. Spec aggregate is k*range_len = 1 KiB; + // the 16 KiB cap bounds bulk-exfil even under chain-param drift. + MaxCompoundAggregateBytes = 16 * 1024 + // LEP6ArtifactClassIndexCutoff is exclusive upper bound for INDEX bucket + // (roll < cutoff -> INDEX). + LEP6ArtifactClassIndexCutoff = 2 +) + +// Domain separator labels used across LEP-6 hash inputs. Freezing these as +// constants prevents accidental drift between callers and tests. +const ( + domainTargetRank = "challenge_target" + domainPairRank = "pair" + domainTicketRank = "ticket_rank" + domainArtifactClass = "artifact_class" + domainArtifactOrdinal = "artifact_ordinal" + domainRangeOffset = "range_offset" + domainDerivationInput = "derivation_input" + domainTranscript = "transcript" +) + +// Stable string forms for proto enums that participate in hash inputs. We +// deliberately use the short proto suffix (INDEX/SYMBOL, RECENT/OLD/...) — not +// the integer varint, which is brittle if the proto enum is ever renumbered, +// and not the full SCREAMING_SNAKE String() form, which is verbose. Once +// frozen, these strings become part of the protocol surface. +var ( + artifactClassDomain = map[audittypes.StorageProofArtifactClass]string{ + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: "INDEX", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: "SYMBOL", + } + bucketDomain = map[audittypes.StorageProofBucketType]string{ + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT: "RECENT", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD: "OLD", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_PROBATION: "PROBATION", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECHECK: "RECHECK", + } +) + +// ArtifactClassDomain returns the canonical hash-input string for the given +// artifact class, or empty string if the class is unspecified or unknown. It +// is exported because PR3+ callers may need to verify a freshly-decoded enum +// has a stable domain string before proceeding. +func ArtifactClassDomain(class audittypes.StorageProofArtifactClass) string { + return artifactClassDomain[class] +} + +// BucketDomain returns the canonical hash-input string for the given bucket +// type, or empty string if the bucket is unspecified or unknown. +func BucketDomain(bucket audittypes.StorageProofBucketType) string { + return bucketDomain[bucket] +} + +// storageTruthAssignmentHash mirrors +// lumera/x/audit/v1/keeper/audit_peer_assignment.go:232 byte-for-byte. It +// computes: +// +// SHA-256(seed || 0x00 || part_0 || 0x00 || part_1 || ... || 0x00 || part_n) +// +// with a NUL byte written before EACH part (not between parts; not after the +// seed alone). No length prefix, no trailing terminator, raw UTF-8 of each +// part string. This is the exact composition the chain validates against. +func storageTruthAssignmentHash(seed []byte, parts ...string) []byte { + h := sha256.New() + _, _ = h.Write(seed) + for _, part := range parts { + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(part)) + } + return h.Sum(nil) +} + +// rankedAccount carries an account/id paired with its sort key. +type rankedAccount struct { + id string + rank []byte +} + +// rankAccounts is the package-internal helper used by SelectLEP6Targets and +// (indirectly) by PairChallengerToTarget. It mirrors rankStorageTruthAccounts +// at audit_peer_assignment.go:214–230 — sort ascending by rank with lex +// tiebreak on id. +func rankAccounts(seed []byte, accounts []string, label string) []rankedAccount { + ranked := make([]rankedAccount, len(accounts)) + for i, a := range accounts { + ranked[i] = rankedAccount{ + id: a, + rank: storageTruthAssignmentHash(seed, a, label), + } + } + sort.Slice(ranked, func(i, j int) bool { + if c := compareBytes(ranked[i].rank, ranked[j].rank); c != 0 { + return c < 0 + } + return ranked[i].id < ranked[j].id + }) + return ranked +} + +// SelectLEP6Targets returns the deterministic challenge-target subset for the +// given epoch seed. Mirrors the chain's per-account ranking exactly: +// targetCount = ceil(len(activeIDs) / divisor), clamped to [1, len(activeIDs)]. +// +// The activeIDs slice is treated as the candidate set as-is. Callers that +// need to deduplicate / sort first must do so before calling — the chain +// itself feeds in `sortedUniqueStrings(activeSorted)` and then takes the +// intersection with explicit `targetsSorted`. For supernode purposes the +// active supernode list is already canonicalised by the chain, so the caller +// typically passes that list straight through. +// +// If divisor is zero, the LEP-6 default (3) is used so partial param overrides +// don't produce 1-target-per-supernode coverage by accident. +func SelectLEP6Targets(activeIDs []string, seed []byte, divisor uint32) []string { + if len(activeIDs) == 0 { + return nil + } + if divisor == 0 { + divisor = LEP6ChallengeTargetDivisor + } + count := (len(activeIDs) + int(divisor) - 1) / int(divisor) + if count < 1 { + count = 1 + } + if count > len(activeIDs) { + count = len(activeIDs) + } + ranked := rankAccounts(seed, activeIDs, domainTargetRank) + out := make([]string, count) + for i := 0; i < count; i++ { + out[i] = ranked[i].id + } + return out +} + +// PairChallengerToTarget assigns one target from `targets` to the given +// challenger using the chain's pair-ranking algorithm. +// +// The chain assigns targets in iteration order over sorted unique active +// challengers, picking for each challenger the smallest-rank unassigned +// target (ties broken lex on target id), and a challenger never gets itself +// as a target. This function reproduces that loop deterministically: for the +// caller's challenger, it selects the unassigned target with smallest pair +// rank that is not equal to the caller, treating `assigned` as the set of +// targets already taken by lower-ranked challengers in the same epoch. +// +// `assigned` may be nil. If non-nil, the function will not return any target +// already present in it. The caller is expected to feed in the fixed-iteration +// view of the assignment as the chain computes it (see +// SelectLEP6Targets + iterate through challengers in deterministic order). +// +// Returns "" if no valid target remains for this challenger. +func PairChallengerToTarget(challenger string, targets []string, seed []byte, assigned map[string]struct{}) string { + bestTarget := "" + var bestRank []byte + for _, t := range targets { + if t == challenger { + continue + } + if assigned != nil { + if _, taken := assigned[t]; taken { + continue + } + } + rank := storageTruthAssignmentHash(seed, challenger, t, domainPairRank) + if bestTarget == "" { + bestTarget = t + bestRank = rank + continue + } + c := compareBytes(rank, bestRank) + if c < 0 || (c == 0 && t < bestTarget) { + bestTarget = t + bestRank = rank + } + } + return bestTarget +} + +// AssignChallengerTargets reproduces the full chain-side challenger→target +// pairing for the entire active set when the target-candidate set is the active +// set. It is provided for tests and for callers who need the complete map (e.g. +// observability emit paths). For runtime use the chain's QueryAssignedTargets +// is the canonical source — call this only when chain access is unavailable or +// for cross-checking. +// +// Iteration order is the lexicographic order of `activeIDs`, matching the +// chain's `sortedUniqueStrings(activeSorted)` precondition. Callers who need +// to feed in an already-sorted unique slice may; otherwise the function does +// not modify its inputs. +func AssignChallengerTargets(activeIDs, targets []string, seed []byte) map[string]string { + return AssignChallengerTargetsWithCandidates(activeIDs, targets, activeIDs, seed) +} + +// AssignChallengerTargetsWithCandidates mirrors Lumera's final +// audit_peer_assignment.go pairing loop, including the self-target fallback: if +// the selected target set has no available non-self target for a challenger, the +// chain scans the full ranked candidate set and picks the best non-self, +// unassigned candidate. This matters when a selected target would otherwise be +// assigned to itself. +func AssignChallengerTargetsWithCandidates(activeIDs, selectedTargets, targetCandidates []string, seed []byte) map[string]string { + if len(activeIDs) == 0 || len(selectedTargets) == 0 { + return map[string]string{} + } + challengers := sortedUniqueCopy(activeIDs) + rankedCandidates := rankAccounts(seed, sortedUniqueCopy(targetCandidates), domainTargetRank) + if len(rankedCandidates) == 0 { + rankedCandidates = rankAccounts(seed, challengers, domainTargetRank) + } + + assigned := make(map[string]struct{}, len(selectedTargets)) + unassignedSelected := make(map[string]struct{}, len(selectedTargets)) + for _, target := range selectedTargets { + if target != "" { + unassignedSelected[target] = struct{}{} + } + } + + uniqueTargetCount := len(unassignedSelected) + out := make(map[string]string, len(challengers)) + for _, challenger := range challengers { + if len(assigned) >= uniqueTargetCount { + break + } + + bestTarget := "" + var bestRank []byte + for target := range unassignedSelected { + if target == challenger { + continue + } + rank := storageTruthAssignmentHash(seed, challenger, target, domainPairRank) + if bestTarget == "" || compareBytes(rank, bestRank) < 0 || (compareBytes(rank, bestRank) == 0 && target < bestTarget) { + bestTarget = target + bestRank = rank + } + } + + if bestTarget == "" { + for _, target := range rankedCandidates { + if _, alreadyAssigned := assigned[target.id]; alreadyAssigned || target.id == challenger { + continue + } + rank := storageTruthAssignmentHash(seed, challenger, target.id, domainPairRank) + if bestTarget == "" || compareBytes(rank, bestRank) < 0 || (compareBytes(rank, bestRank) == 0 && target.id < bestTarget) { + bestTarget = target.id + bestRank = rank + } + } + if bestTarget == "" { + continue + } + } + + delete(unassignedSelected, bestTarget) + assigned[bestTarget] = struct{}{} + out[challenger] = bestTarget + } + return out +} + +// ClassifyTicketBucket classifies a ticket into a LEP-6 bucket based on its +// on-chain anchor height. Per LEP-6 §8 (and the chain's bucket-default fix in +// `LEP-6-consensus-gap-fixes`), bucket boundaries derive from the chain's +// epoch span: +// +// - RECENT if currentHeight - anchorHeight <= recentBucketMaxBlocks (default 3 * epoch_length_blocks) +// - OLD if currentHeight - anchorHeight >= oldBucketMinBlocks (default 30 * epoch_length_blocks) +// - else UNSPECIFIED (middle bucket — eligible only for rechecks / +// probation per LEP-6 §8) +// +// The "anchor height" is the cascade Action's BlockHeight (set at +// RegisterAction; not updated at finalization). Action.UpdatedHeight does not +// exist — see PR2 implementation note in +// docs/plans/LEP6_SUPERNODE_IMPLEMENTATION_PLAN.md §"Resolved Decision 3". +// +// If currentHeight < anchorHeight (clock-skew or replay scenarios), the +// classification falls through to UNSPECIFIED. +func ClassifyTicketBucket(currentHeight, anchorHeight int64, recentBucketMaxBlocks, oldBucketMinBlocks uint64) audittypes.StorageProofBucketType { + if currentHeight < anchorHeight { + return audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED + } + delta := uint64(currentHeight - anchorHeight) + if delta <= recentBucketMaxBlocks { + return audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT + } + if delta >= oldBucketMinBlocks { + return audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD + } + return audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED +} + +// SelectTicketForBucket picks one ticket deterministically for a given +// (target, bucket) pair from a pool of eligible tickets, excluding any tickets +// the caller marks as ineligible (e.g. tickets with an active heal op per +// LEP-6 §9 step 2). +// +// Rank: SHA-256(seed || 0x00 || target || 0x00 || bucket || 0x00 || ticket_id || 0x00 || "ticket_rank") +// — where bucket is the BucketDomain() string ("RECENT", "OLD", "PROBATION", +// or "RECHECK"). Ascending sort, lex tiebreak on ticket id. +// +// Returns "" if no eligible ticket remains, signalling NO_ELIGIBLE_TICKET to +// the caller per LEP-6 §9. +func SelectTicketForBucket(eligibleTicketIDs []string, excluded map[string]struct{}, seed []byte, target string, bucket audittypes.StorageProofBucketType) string { + bucketStr := BucketDomain(bucket) + if bucketStr == "" { + return "" + } + bestTicket := "" + var bestRank []byte + for _, t := range eligibleTicketIDs { + if t == "" { + continue + } + if excluded != nil { + if _, skip := excluded[t]; skip { + continue + } + } + rank := storageTruthAssignmentHash(seed, target, bucketStr, t, domainTicketRank) + if bestTicket == "" { + bestTicket = t + bestRank = rank + continue + } + c := compareBytes(rank, bestRank) + if c < 0 || (c == 0 && t < bestTicket) { + bestTicket = t + bestRank = rank + } + } + return bestTicket +} + +// SelectArtifactClass implements the LEP-6 §10 step 1 class roll: +// +// class_roll = SHA-256(seed || 0x00 || target || 0x00 || ticket_id || 0x00 || "artifact_class")[:8] (big-endian uint64) mod 10 +// class_roll < 2 -> INDEX, else SYMBOL +// +// If the rolled class has zero artifacts, returns UNSPECIFIED — the caller +// MUST emit NO_ELIGIBLE_TICKET for that (target, bucket) slot. Cross-class +// fallback is intentionally NOT performed: the chain does not mirror a +// supernode-side swap (see lumera@v1.12.0 +// x/audit/v1/keeper/msg_submit_epoch_report_storage_proofs.go:120-128 — chain +// only validates that ArtifactClass is INDEX or SYMBOL and that +// (class, ordinal) is consistent with the anchored count for that ticket; it +// does not re-derive the class roll). Per LEP-6 §14, the artifact class +// affects D/N delta routing, so a supernode-side swap would land deltas in +// the wrong scoring bucket relative to a peer that did not swap. +// +// LEP-6 review (Matee, 2026-05-06) — H6: emitting NO_ELIGIBLE_TICKET is the +// safer, deterministically reproducible result; chain has consistency checks +// for NO_ELIGIBLE that still surface real coverage gaps. +func SelectArtifactClass(seed []byte, target, ticketID string, indexCount, symbolCount uint32) audittypes.StorageProofArtifactClass { + if indexCount == 0 && symbolCount == 0 { + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED + } + rollHash := storageTruthAssignmentHash(seed, target, ticketID, domainArtifactClass) + roll := binary.BigEndian.Uint64(rollHash[:8]) % LEP6ArtifactClassRollModulus + preferIndex := roll < LEP6ArtifactClassIndexCutoff + if preferIndex { + if indexCount > 0 { + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX + } + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED + } + if symbolCount > 0 { + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL + } + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED +} + +// SelectArtifactOrdinal implements LEP-6 §10 step 2: +// +// artifact_ordinal = SHA-256(seed || 0x00 || target || 0x00 || ticket_id || 0x00 || class_domain || 0x00 || "artifact_ordinal")[:8] (big-endian uint64) mod artifactCount +// +// Returns an error if artifactCount is zero (caller must have already +// validated the class has artifacts via SelectArtifactClass). Returns an +// error for unsupported classes. +func SelectArtifactOrdinal(seed []byte, target, ticketID string, class audittypes.StorageProofArtifactClass, artifactCount uint32) (uint32, error) { + if artifactCount == 0 { + return 0, fmt.Errorf("deterministic.SelectArtifactOrdinal: artifactCount must be > 0") + } + classDomain := ArtifactClassDomain(class) + if classDomain == "" { + return 0, fmt.Errorf("deterministic.SelectArtifactOrdinal: unsupported class %v", class) + } + h := storageTruthAssignmentHash(seed, target, ticketID, classDomain, domainArtifactOrdinal) + return uint32(binary.BigEndian.Uint64(h[:8]) % uint64(artifactCount)), nil +} + +// ComputeMultiRangeOffsets produces the LEP-6 §11 deterministic byte-range +// offsets for a single artifact challenge: +// +// offset_i = SHA-256(seed || 0x00 || target || 0x00 || ticket_id || +// 0x00 || class_domain || 0x00 || u32be(ordinal) || +// 0x00 || u32be(i))[:8] (big-endian uint64) +// mod (artifactSize - rangeLen) +// +// Defaults: k=4 ranges, rangeLen=256 bytes (LEP-6 spec values). Both must be +// passed explicitly so a future param change at the chain level can be +// surfaced cleanly. The returned slice has length exactly k. +// +// Returns an error if rangeLen >= artifactSize (would yield negative modulus +// space) or if any input is degenerate (k=0, empty class). +// +// IMPORTANT: u32be(ordinal) and u32be(i) are written as raw 4-byte +// big-endian integers, not as decimal-string forms — this keeps the byte +// schema unambiguous and length-stable. If you change to decimal, you must +// version the domain separator and update the protocol guide. +func ComputeMultiRangeOffsets(seed []byte, target, ticketID string, class audittypes.StorageProofArtifactClass, ordinal uint32, artifactSize, rangeLen uint64, k int) ([]uint64, error) { + if k <= 0 { + return nil, fmt.Errorf("deterministic.ComputeMultiRangeOffsets: k must be > 0") + } + if rangeLen == 0 { + return nil, fmt.Errorf("deterministic.ComputeMultiRangeOffsets: rangeLen must be > 0") + } + if artifactSize <= rangeLen { + return nil, fmt.Errorf("deterministic.ComputeMultiRangeOffsets: artifactSize (%d) must be > rangeLen (%d)", artifactSize, rangeLen) + } + classDomain := ArtifactClassDomain(class) + if classDomain == "" { + return nil, fmt.Errorf("deterministic.ComputeMultiRangeOffsets: unsupported class %v", class) + } + span := artifactSize - rangeLen + offsets := make([]uint64, k) + var ordBuf, idxBuf [4]byte + binary.BigEndian.PutUint32(ordBuf[:], ordinal) + for i := 0; i < k; i++ { + binary.BigEndian.PutUint32(idxBuf[:], uint32(i)) + // We deliberately reach into the same `seed || 0x00 || part || ...` + // composition by passing the binary parts as Go strings (the helper + // takes string parts, but Go strings are byte sequences and may + // contain arbitrary bytes including NULs without issue here — the + // helper writes []byte(part) raw). + h := storageTruthAssignmentHash(seed, + target, + ticketID, + classDomain, + string(ordBuf[:]), + string(idxBuf[:]), + domainRangeOffset, + ) + offsets[i] = binary.BigEndian.Uint64(h[:8]) % span + } + return offsets, nil +} + +// ComputeCompoundChallengeHash computes the BLAKE3-256 hash of the +// concatenation of `len(offsets)` byte ranges, each `rangeLen` bytes long, at +// the given offsets within `data`. This is the proof-construction primitive +// per LEP-6 §11: +// +// challenge_hash = blake3(slice_0 || slice_1 || ... || slice_{k-1}) +// +// Slices are read in the order provided (NOT sorted) so the caller's chosen +// offset ordering — which matches ComputeMultiRangeOffsets' i=0..k-1 — is +// preserved and reproducible by observers. +// +// Returns an error if any offset+rangeLen exceeds len(data). +func ComputeCompoundChallengeHash(data []byte, offsets []uint64, rangeLen uint64) ([32]byte, error) { + var zero [32]byte + if rangeLen == 0 { + return zero, fmt.Errorf("deterministic.ComputeCompoundChallengeHash: rangeLen must be > 0") + } + if rangeLen > math.MaxInt { + return zero, fmt.Errorf("deterministic.ComputeCompoundChallengeHash: rangeLen too large") + } + dataLen := uint64(len(data)) + h := blake3.New(32, nil) + for i, off := range offsets { + end := off + rangeLen + if end < off || end > dataLen { + return zero, fmt.Errorf("deterministic.ComputeCompoundChallengeHash: range %d (offset=%d len=%d) exceeds data size %d", i, off, rangeLen, dataLen) + } + _, _ = h.Write(data[off:end]) + } + var out [32]byte + copy(out[:], h.Sum(nil)) + return out, nil +} + +// DerivationInputHash produces the canonical hex string the supernode submits +// as `StorageProofResult.derivation_input_hash`. The chain stores it as an +// opaque non-empty string and uses it for transcript indexing only; this +// function defines the canonical encoding so that two reporters challenging +// the same (target, ticket, class, ordinal, offsets) combination produce +// identical hashes. +// +// Encoding: +// +// SHA-256(seed || 0x00 || target || 0x00 || ticket_id || 0x00 || +// class_domain || 0x00 || u32be(ordinal) || 0x00 || +// u64be(rangeLen) || 0x00 || u64be(offset_0) || ... || +// 0x00 || u64be(offset_{k-1}) || 0x00 || "derivation_input") +// +// Returned as lowercase hex (no 0x prefix, length 64). +func DerivationInputHash(seed []byte, target, ticketID string, class audittypes.StorageProofArtifactClass, ordinal uint32, offsets []uint64, rangeLen uint64) (string, error) { + classDomain := ArtifactClassDomain(class) + if classDomain == "" { + return "", fmt.Errorf("deterministic.DerivationInputHash: unsupported class %v", class) + } + parts := make([]string, 0, 4+len(offsets)+1) + parts = append(parts, target, ticketID, classDomain) + + var ordBuf [4]byte + binary.BigEndian.PutUint32(ordBuf[:], ordinal) + parts = append(parts, string(ordBuf[:])) + + var lenBuf [8]byte + binary.BigEndian.PutUint64(lenBuf[:], rangeLen) + parts = append(parts, string(lenBuf[:])) + + for _, off := range offsets { + var offBuf [8]byte + binary.BigEndian.PutUint64(offBuf[:], off) + parts = append(parts, string(offBuf[:])) + } + parts = append(parts, domainDerivationInput) + + return hex.EncodeToString(storageTruthAssignmentHash(seed, parts...)), nil +} + +// TranscriptInputs bundles the fields that go into TranscriptHash. Using a +// struct keeps the call site readable and makes the input ordering explicit — +// any caller who tries to reorder fields will hit the field-name resolution +// at compile time, not at hash-compare time. +type TranscriptInputs struct { + EpochID uint64 + ChallengerSupernodeAccount string + TargetSupernodeAccount string + TicketID string + Bucket audittypes.StorageProofBucketType + ArtifactClass audittypes.StorageProofArtifactClass + ArtifactOrdinal uint32 + ArtifactKey string + DerivationInputHash string // hex from DerivationInputHash (or empty for NO_ELIGIBLE_TICKET) + CompoundProofHashHex string // hex of ComputeCompoundChallengeHash output (or empty for NO_ELIGIBLE_TICKET) + ObserverIDs []string +} + +// TranscriptHash produces the canonical hex string the supernode submits as +// `StorageProofResult.transcript_hash`. +// +// Encoding: +// +// SHA-256(seed=u64be(epoch_id) || 0x00 || challenger || 0x00 || target || +// 0x00 || ticket_id || 0x00 || bucket_domain || 0x00 || +// class_domain || 0x00 || u32be(ordinal) || 0x00 || artifact_key || +// 0x00 || derivation_input_hash || 0x00 || compound_proof_hash_hex || +// 0x00 || u32be(len(observer_ids)) || +// 0x00 || observer_id_0 || ... || 0x00 || observer_id_n-1 || +// 0x00 || "transcript") +// +// Note that the "seed" passed into storageTruthAssignmentHash here is +// u64be(epoch_id) — not the epoch anchor seed — because transcripts are +// epoch-scoped identifiers and the epoch anchor seed already commits to the +// chain state at that height. Including the epoch id directly keeps the +// transcript portable across replay scenarios. +// +// Observer ids are sorted lex before hashing so observer-set permutations do +// not produce different transcripts for the same logical proof. +// +// Returns lowercase hex. +func TranscriptHash(in TranscriptInputs) (string, error) { + bucketDom := BucketDomain(in.Bucket) + if bucketDom == "" { + return "", fmt.Errorf("deterministic.TranscriptHash: unsupported bucket %v", in.Bucket) + } + // Class is allowed to be UNSPECIFIED only in the NO_ELIGIBLE_TICKET + // transcript shape (TicketID == ""). For all other inputs we require a + // known class. + classDom := ArtifactClassDomain(in.ArtifactClass) + if classDom == "" { + if in.TicketID != "" { + return "", fmt.Errorf("deterministic.TranscriptHash: unsupported class %v with non-empty ticket", in.ArtifactClass) + } + classDom = "UNSPECIFIED" + } + + var epochSeed [8]byte + binary.BigEndian.PutUint64(epochSeed[:], in.EpochID) + + var ordBuf [4]byte + binary.BigEndian.PutUint32(ordBuf[:], in.ArtifactOrdinal) + + observers := append([]string(nil), in.ObserverIDs...) + sort.Strings(observers) + var obsCount [4]byte + binary.BigEndian.PutUint32(obsCount[:], uint32(len(observers))) + + parts := make([]string, 0, 11+len(observers)) + parts = append(parts, + in.ChallengerSupernodeAccount, + in.TargetSupernodeAccount, + in.TicketID, + bucketDom, + classDom, + string(ordBuf[:]), + in.ArtifactKey, + in.DerivationInputHash, + in.CompoundProofHashHex, + string(obsCount[:]), + ) + parts = append(parts, observers...) + parts = append(parts, domainTranscript) + + return hex.EncodeToString(storageTruthAssignmentHash(epochSeed[:], parts...)), nil +} + +func sortedUniqueCopy(in []string) []string { + if len(in) == 0 { + return nil + } + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, v := range in { + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} + +// compareBytes is bytes.Compare; inlined to avoid the bytes import for a +// single call site. +func compareBytes(a, b []byte) int { + la, lb := len(a), len(b) + n := la + if lb < n { + n = lb + } + for i := 0; i < n; i++ { + if a[i] != b[i] { + if a[i] < b[i] { + return -1 + } + return 1 + } + } + switch { + case la < lb: + return -1 + case la > lb: + return 1 + default: + return 0 + } +} diff --git a/pkg/storagechallenge/deterministic/lep6_test.go b/pkg/storagechallenge/deterministic/lep6_test.go new file mode 100644 index 00000000..43585ab5 --- /dev/null +++ b/pkg/storagechallenge/deterministic/lep6_test.go @@ -0,0 +1,759 @@ +package deterministic + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "sort" + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "lukechampine.com/blake3" +) + +// chainSeed reproduces the test fixture used by the chain's +// audit_peer_assignment_test.go::TestStorageTruthAssignmentUsesOneThirdCoverage. +// Keeping it identical here lets us cross-check supernode ↔ chain behaviour +// against the same input. +var chainSeed = []byte("01234567890123456789012345678901") + +func TestStorageTruthAssignmentHash_KnownVector(t *testing.T) { + // Byte-level expectation locked against an independent SHA-256 + // computation of the chain's exact byte composition: + // seed || 0x00 || "sn-a" || 0x00 || "challenge_target" + got := storageTruthAssignmentHash(chainSeed, "sn-a", "challenge_target") + wantHex := "bf2bd1e684b3640d2bb047f4d71db719f8f4aa2c3b1601df492115f6e3552b7f" + want, _ := hex.DecodeString(wantHex) + if !bytes.Equal(got, want) { + t.Fatalf("storageTruthAssignmentHash mismatch\nwant %s\ngot %s", wantHex, hex.EncodeToString(got)) + } + + // Spot-check the helper interleaves NULs correctly — direct SHA-256 of + // the equivalent byte stream must match. + h := sha256.New() + h.Write(chainSeed) + h.Write([]byte{0}) + h.Write([]byte("sn-a")) + h.Write([]byte{0}) + h.Write([]byte("challenge_target")) + if !bytes.Equal(got, h.Sum(nil)) { + t.Fatalf("storageTruthAssignmentHash diverges from inline SHA-256") + } +} + +func TestSelectLEP6Targets_OneThirdCoverage_AssignmentMatchesChain(t *testing.T) { + active := []string{"sn-a", "sn-b", "sn-c", "sn-d", "sn-e", "sn-f"} + targets := SelectLEP6Targets(active, chainSeed, 3) + // Chain test asserts targetCount == 2 with len(active)=6, divisor=3. + if len(targets) != 2 { + t.Fatalf("expected 2 targets, got %d (%v)", len(targets), targets) + } + // Independently computed below in a Python sketch (see PR description); + // freezing here as a regression vector. + want := []string{"sn-f", "sn-e"} + if !equalSliceOrdered(targets, want) { + t.Fatalf("targets mismatch\nwant %v\ngot %v", want, targets) + } +} + +func TestAssignChallengerTargets_KnownAssignment(t *testing.T) { + active := []string{"sn-a", "sn-b", "sn-c", "sn-d", "sn-e", "sn-f"} + targets := SelectLEP6Targets(active, chainSeed, 3) + got := AssignChallengerTargets(active, targets, chainSeed) + want := map[string]string{"sn-a": "sn-f", "sn-b": "sn-e"} + if len(got) != len(want) { + t.Fatalf("assignment size mismatch\nwant %v\ngot %v", want, got) + } + for k, v := range want { + if got[k] != v { + t.Fatalf("assignment[%s] = %s, want %s (full got=%v)", k, got[k], v, got) + } + } + // Self-assignment must never happen. + for c, tg := range got { + if c == tg { + t.Fatalf("challenger %s was assigned to itself", c) + } + } + // All assigned targets must come from the SelectLEP6Targets set. + allowed := map[string]struct{}{} + for _, x := range targets { + allowed[x] = struct{}{} + } + for _, tg := range got { + if _, ok := allowed[tg]; !ok { + t.Fatalf("assigned target %s not in target set %v", tg, targets) + } + } + // No two challengers share the same target. + seen := map[string]string{} + for c, tg := range got { + if prev, dup := seen[tg]; dup { + t.Fatalf("target %s assigned to both %s and %s", tg, prev, c) + } + seen[tg] = c + } +} + +func TestAssignChallengerTargets_SelfSelectedTargetFallsBackLikeChain(t *testing.T) { + active := []string{"sn-a", "sn-b"} + // Force the final Lumera edge case: only selected target is the first + // challenger itself. Chain audit_peer_assignment.go then falls back to the + // full ranked candidate set and assigns the best non-self candidate instead + // of returning no assignment. + got := AssignChallengerTargetsWithCandidates(active, []string{"sn-a"}, active, chainSeed) + if got["sn-a"] != "sn-b" { + t.Fatalf("expected self-target fallback sn-a→sn-b, got %v", got) + } + if _, assignedSecond := got["sn-b"]; assignedSecond { + t.Fatalf("targetCount=1 should stop after one assignment, got %v", got) + } +} + +func TestAssignChallengerTargets_DeduplicatesSelectedTargetsForStopCondition(t *testing.T) { + active := []string{"sn-a", "sn-b", "sn-c"} + got := AssignChallengerTargetsWithCandidates(active, []string{"sn-a", "sn-a", ""}, active, chainSeed) + if len(got) != 1 { + t.Fatalf("duplicate/empty selected targets should count as one unique target, got %v", got) + } + for challenger, target := range got { + if challenger == target { + t.Fatalf("challenger %s was assigned to itself", challenger) + } + } +} + +func TestSelectLEP6Targets_SmallSets(t *testing.T) { + // targetCount must always be ≥1 even when divisor > activeCount. + got := SelectLEP6Targets([]string{"sn-a", "sn-b"}, chainSeed, 3) + if len(got) != 1 { + t.Fatalf("targetCount should clamp to 1, got %d (%v)", len(got), got) + } + got = SelectLEP6Targets([]string{"sn-a"}, chainSeed, 3) + if len(got) != 1 || got[0] != "sn-a" { + t.Fatalf("singleton should pass through, got %v", got) + } + got = SelectLEP6Targets(nil, chainSeed, 3) + if got != nil { + t.Fatalf("nil input should yield nil, got %v", got) + } + // Divisor zero defaults to LEP6ChallengeTargetDivisor. + a := SelectLEP6Targets([]string{"sn-a", "sn-b", "sn-c"}, chainSeed, 0) + b := SelectLEP6Targets([]string{"sn-a", "sn-b", "sn-c"}, chainSeed, LEP6ChallengeTargetDivisor) + if !equalSliceOrdered(a, b) { + t.Fatalf("divisor=0 should default to %d; %v != %v", LEP6ChallengeTargetDivisor, a, b) + } +} + +func TestSelectLEP6Targets_DeterministicAcrossRuns(t *testing.T) { + active := []string{"x", "y", "z", "a", "b", "c", "d", "e", "f", "g", "h"} + first := SelectLEP6Targets(active, chainSeed, 4) + for i := 0; i < 50; i++ { + got := SelectLEP6Targets(active, chainSeed, 4) + if !equalSliceOrdered(first, got) { + t.Fatalf("non-deterministic on run %d: %v != %v", i, first, got) + } + } +} + +func TestPairChallengerToTarget_NoSelfTarget(t *testing.T) { + got := PairChallengerToTarget("sn-a", []string{"sn-a", "sn-b", "sn-c"}, chainSeed, nil) + if got == "sn-a" { + t.Fatalf("PairChallengerToTarget must not return self; got %s", got) + } + if got != "sn-b" && got != "sn-c" { + t.Fatalf("unexpected target %s", got) + } +} + +func TestPairChallengerToTarget_RespectsAssigned(t *testing.T) { + all := []string{"sn-b", "sn-c", "sn-d"} + assigned := map[string]struct{}{"sn-b": {}, "sn-c": {}} + got := PairChallengerToTarget("sn-a", all, chainSeed, assigned) + if got != "sn-d" { + t.Fatalf("expected sn-d (only unassigned), got %s", got) + } + // All taken → empty. + full := map[string]struct{}{"sn-b": {}, "sn-c": {}, "sn-d": {}} + got = PairChallengerToTarget("sn-a", all, chainSeed, full) + if got != "" { + t.Fatalf("expected empty when all targets taken, got %s", got) + } +} + +func TestClassifyTicketBucket_Boundaries(t *testing.T) { + // recent ≤ 3*epoch, old ≥ 30*epoch — using 400-block epochs. + const epoch = 400 + recent := uint64(3 * epoch) // 1200 + old := uint64(30 * epoch) // 12000 + cases := []struct { + name string + anchor int64 + now int64 + want audittypes.StorageProofBucketType + }{ + {"current_block", 1000, 1000, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT}, + {"recent_inside", 1000, 1000 + int64(recent) - 1, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT}, + {"recent_boundary", 1000, 1000 + int64(recent), audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT}, + {"middle_just_after_recent", 1000, 1000 + int64(recent) + 1, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED}, + {"middle_just_before_old", 1000, 1000 + int64(old) - 1, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED}, + {"old_boundary", 1000, 1000 + int64(old), audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD}, + {"old_far", 1000, 1000 + int64(old) + 5000, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD}, + {"future_anchor_falls_through", 2000, 1000, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ClassifyTicketBucket(tc.now, tc.anchor, recent, old) + if got != tc.want { + t.Fatalf("anchor=%d now=%d → %v, want %v", tc.anchor, tc.now, got, tc.want) + } + }) + } +} + +func TestSelectTicketForBucket_DeterministicAndExcludes(t *testing.T) { + tickets := []string{"t1", "t2", "t3", "t4", "t5"} + bucket := audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT + a := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", bucket) + if a == "" { + t.Fatal("expected a ticket, got empty") + } + for i := 0; i < 50; i++ { + b := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", bucket) + if a != b { + t.Fatalf("non-deterministic ticket selection: %s vs %s on run %d", a, b, i) + } + } + // Exclude the chosen one — must pick a different ticket. + excl := map[string]struct{}{a: {}} + b := SelectTicketForBucket(tickets, excl, chainSeed, "sn-target", bucket) + if b == "" || b == a { + t.Fatalf("exclusion broken: a=%s, b=%s", a, b) + } + // Exclude all — must yield empty. + allExcluded := map[string]struct{}{} + for _, t := range tickets { + allExcluded[t] = struct{}{} + } + c := SelectTicketForBucket(tickets, allExcluded, chainSeed, "sn-target", bucket) + if c != "" { + t.Fatalf("expected empty when all excluded, got %s", c) + } + // Different bucket → may pick a different ticket (and must be deterministic). + d := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD) + if d == "" { + t.Fatal("OLD bucket should also produce a ticket") + } + d2 := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD) + if d != d2 { + t.Fatalf("OLD bucket non-deterministic: %s vs %s", d, d2) + } + // Empty tickets → empty result, no panic. + if got := SelectTicketForBucket(nil, nil, chainSeed, "sn-target", bucket); got != "" { + t.Fatalf("nil input should give empty, got %s", got) + } +} + +func TestSelectTicketForBucket_UsesTicketRankDomainSeparator(t *testing.T) { + tickets := []string{"t1", "t2"} + got := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT) + if got != "t2" { + t.Fatalf("expected ticket_rank-domain selection t2, got %s", got) + } + + wantRank := storageTruthAssignmentHash(chainSeed, "sn-target", "RECENT", "t2", domainTicketRank) + withoutDomain := storageTruthAssignmentHash(chainSeed, "sn-target", "RECENT", "t2") + if bytes.Equal(wantRank, withoutDomain) { + t.Fatalf("ticket_rank domain separator must change the ticket ranking hash") + } +} + +func TestSelectArtifactClass_WeightedDistribution(t *testing.T) { + // 20% INDEX, 80% SYMBOL over many ticket draws. + indexN, symbolN := 0, 0 + for i := 0; i < 5000; i++ { + ticket := "t-" + ifmt(i) + c := SelectArtifactClass(chainSeed, "sn-target", ticket, 100, 100) + switch c { + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: + indexN++ + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: + symbolN++ + default: + t.Fatalf("unexpected class %v on draw %d", c, i) + } + } + total := indexN + symbolN + idxFrac := float64(indexN) / float64(total) + // Expected 0.2, allow ±2% tolerance for 5000 draws. + if idxFrac < 0.18 || idxFrac > 0.22 { + t.Fatalf("INDEX fraction %.4f outside expected 0.18-0.22 (n=%d/%d)", idxFrac, indexN, total) + } +} + +func TestSelectArtifactClass_NoSwapWhenRolledClassEmpty(t *testing.T) { + // LEP-6 review H6: rolled class empty → UNSPECIFIED (caller must emit + // NO_ELIGIBLE_TICKET). No cross-class fallback — chain does not mirror + // such a swap, so swapping would corrupt N/R/D delta routing per §14. + indexCutoffMet, symbolCutoffMet := 0, 0 + for i := 0; i < 200; i++ { + // indexCount=0 → INDEX rolls land on UNSPECIFIED; SYMBOL rolls land on SYMBOL. + c := SelectArtifactClass(chainSeed, "sn-target", "t-"+ifmt(i), 0, 50) + switch c { + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED: + indexCutoffMet++ + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: + symbolCutoffMet++ + default: + t.Fatalf("with indexCount=0, expected UNSPECIFIED or SYMBOL; got %v", c) + } + } + if indexCutoffMet == 0 || symbolCutoffMet == 0 { + t.Fatalf("distribution sanity failed: index-roll-empty=%d symbol-roll-symbol=%d", indexCutoffMet, symbolCutoffMet) + } + // symbolCount=0 → SYMBOL rolls land on UNSPECIFIED; INDEX rolls land on INDEX. + indexCutoffMet, symbolCutoffMet = 0, 0 + for i := 0; i < 200; i++ { + c := SelectArtifactClass(chainSeed, "sn-target", "t-"+ifmt(i), 50, 0) + switch c { + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: + indexCutoffMet++ + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED: + symbolCutoffMet++ + default: + t.Fatalf("with symbolCount=0, expected INDEX or UNSPECIFIED; got %v", c) + } + } + if indexCutoffMet == 0 || symbolCutoffMet == 0 { + t.Fatalf("distribution sanity failed: index-roll-index=%d symbol-roll-empty=%d", indexCutoffMet, symbolCutoffMet) + } + // Both zero → UNSPECIFIED. + if c := SelectArtifactClass(chainSeed, "sn-target", "t1", 0, 0); c != audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED { + t.Fatalf("with both zero, expected UNSPECIFIED; got %v", c) + } +} + +func TestSelectArtifactOrdinal_BoundsAndDeterminism(t *testing.T) { + const count = 64 + first, err := SelectArtifactOrdinal(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, count) + if err != nil { + t.Fatalf("err: %v", err) + } + if first >= count { + t.Fatalf("ordinal out of range: %d", first) + } + for i := 0; i < 50; i++ { + again, _ := SelectArtifactOrdinal(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, count) + if again != first { + t.Fatalf("non-deterministic: %d vs %d", first, again) + } + } + // Errors: + if _, err := SelectArtifactOrdinal(chainSeed, "sn-target", "t", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0); err == nil { + t.Fatal("expected error for count=0") + } + if _, err := SelectArtifactOrdinal(chainSeed, "sn-target", "t", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, 5); err == nil { + t.Fatal("expected error for unspecified class") + } +} + +func TestComputeMultiRangeOffsets_AllInBounds(t *testing.T) { + const size, rl = uint64(10000), uint64(256) + offsets, err := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0, size, rl, 4) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(offsets) != 4 { + t.Fatalf("expected 4 offsets, got %d", len(offsets)) + } + for i, off := range offsets { + if off+rl > size { + t.Fatalf("offset %d (%d + %d) exceeds size %d", i, off, rl, size) + } + } + // Determinism. + o2, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0, size, rl, 4) + if !equalSliceUint64(offsets, o2) { + t.Fatalf("non-deterministic offsets: %v vs %v", offsets, o2) + } +} + +func TestComputeMultiRangeOffsets_OffsetsDistinctOnDifferentInputs(t *testing.T) { + const size, rl = uint64(10000), uint64(256) + a, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0, size, rl, 4) + b, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-2", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0, size, rl, 4) + if equalSliceUint64(a, b) { + t.Fatalf("different ticket should change offsets, got identical %v", a) + } + c, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, 0, size, rl, 4) + if equalSliceUint64(a, c) { + t.Fatalf("different class should change offsets, got identical %v", a) + } + d, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 1, size, rl, 4) + if equalSliceUint64(a, d) { + t.Fatalf("different ordinal should change offsets, got identical %v", a) + } +} + +func TestComputeMultiRangeOffsets_Errors(t *testing.T) { + cls := audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL + if _, err := ComputeMultiRangeOffsets(chainSeed, "x", "t", cls, 0, 100, 256, 4); err == nil { + t.Fatal("expected error when rangeLen >= artifactSize") + } + if _, err := ComputeMultiRangeOffsets(chainSeed, "x", "t", cls, 0, 1000, 256, 0); err == nil { + t.Fatal("expected error for k=0") + } + if _, err := ComputeMultiRangeOffsets(chainSeed, "x", "t", cls, 0, 1000, 0, 4); err == nil { + t.Fatal("expected error for rangeLen=0") + } + if _, err := ComputeMultiRangeOffsets(chainSeed, "x", "t", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, 0, 1000, 256, 4); err == nil { + t.Fatal("expected error for unspecified class") + } +} + +func TestComputeCompoundChallengeHash_KnownVector(t *testing.T) { + // 1024-byte input filled with byte values 0..255 cycling. + data := make([]byte, 1024) + for i := range data { + data[i] = byte(i) + } + offsets := []uint64{0, 256, 512, 768} + rl := uint64(256) + got, err := ComputeCompoundChallengeHash(data, offsets, rl) + if err != nil { + t.Fatalf("err: %v", err) + } + // Independent reference computation. + h := blake3.New(32, nil) + for _, off := range offsets { + h.Write(data[off : off+rl]) + } + var want [32]byte + copy(want[:], h.Sum(nil)) + if got != want { + t.Fatalf("compound hash mismatch\nwant %x\ngot %x", want, got) + } +} + +func TestComputeCompoundChallengeHash_OrderMatters(t *testing.T) { + // Build data where each 256-byte slice has a unique signature: fill with + // `byte(off >> 8)` so slice [0:256] = 0x00…, [256:512] = 0x01…, etc. + data := make([]byte, 1024) + for i := range data { + data[i] = byte(i >> 8) + } + a, _ := ComputeCompoundChallengeHash(data, []uint64{0, 256, 512, 768}, 256) + b, _ := ComputeCompoundChallengeHash(data, []uint64{768, 512, 256, 0}, 256) + if a == b { + t.Fatal("compound hash should be order-sensitive (slices concatenated in offset order)") + } +} + +func TestComputeCompoundChallengeHash_OutOfBounds(t *testing.T) { + data := make([]byte, 100) + if _, err := ComputeCompoundChallengeHash(data, []uint64{50}, 60); err == nil { + t.Fatal("expected error for out-of-bounds slice") + } + if _, err := ComputeCompoundChallengeHash(data, []uint64{100}, 1); err == nil { + t.Fatal("expected error when offset == len(data)") + } +} + +func TestDerivationInputHash_DeterministicAndSensitive(t *testing.T) { + cls := audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL + a, err := DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 4}, 256) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(a) != 64 { + t.Fatalf("expected 64-char hex, got %d (%q)", len(a), a) + } + b, _ := DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 4}, 256) + if a != b { + t.Fatalf("non-deterministic: %s vs %s", a, b) + } + // Each input field must change the hash. + cases := []struct { + name string + fn func() (string, error) + }{ + {"ticket", func() (string, error) { + return DerivationInputHash(chainSeed, "sn-target", "ticket-2", cls, 7, []uint64{1, 2, 3, 4}, 256) + }}, + {"ordinal", func() (string, error) { + return DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 8, []uint64{1, 2, 3, 4}, 256) + }}, + {"offsets", func() (string, error) { + return DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 5}, 256) + }}, + {"rangeLen", func() (string, error) { + return DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 4}, 257) + }}, + {"target", func() (string, error) { + return DerivationInputHash(chainSeed, "other-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 4}, 256) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _ := tc.fn() + if got == a { + t.Fatalf("changing %s did not change hash (%s)", tc.name, got) + } + }) + } +} + +func TestTranscriptHash_DeterministicAndSensitive(t *testing.T) { + in := TranscriptInputs{ + EpochID: 42, + ChallengerSupernodeAccount: "sn-prober", + TargetSupernodeAccount: "sn-target", + TicketID: "ticket-1", + Bucket: audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + ArtifactClass: audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, + ArtifactOrdinal: 3, + ArtifactKey: "p2p-key-abc", + DerivationInputHash: "deadbeef", + CompoundProofHashHex: "feedface", + ObserverIDs: []string{"sn-obs-1", "sn-obs-2"}, + } + a, err := TranscriptHash(in) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(a) != 64 { + t.Fatalf("expected 64-char hex, got %d", len(a)) + } + // Determinism even when observers are in shuffled input order. + in2 := in + in2.ObserverIDs = []string{"sn-obs-2", "sn-obs-1"} + b, _ := TranscriptHash(in2) + if a != b { + t.Fatalf("observer order should be normalised: %s vs %s", a, b) + } + + // NO_ELIGIBLE_TICKET shape (ticket_id == "" with UNSPECIFIED class) is allowed. + noTicket := TranscriptInputs{ + EpochID: 42, + ChallengerSupernodeAccount: "sn-prober", + TargetSupernodeAccount: "sn-target", + TicketID: "", + Bucket: audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + ArtifactClass: audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, + ObserverIDs: []string{"sn-obs-1"}, + } + if _, err := TranscriptHash(noTicket); err != nil { + t.Fatalf("NO_ELIGIBLE_TICKET shape should be valid, err: %v", err) + } + + // UNSPECIFIED class with non-empty ticket → error. + bad := in + bad.ArtifactClass = audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED + if _, err := TranscriptHash(bad); err == nil { + t.Fatal("expected error for UNSPECIFIED class with ticket") + } + + // Each major input must change the hash. + cases := []func(*TranscriptInputs){ + func(x *TranscriptInputs) { x.EpochID = 43 }, + func(x *TranscriptInputs) { x.ChallengerSupernodeAccount = "sn-other-prober" }, + func(x *TranscriptInputs) { x.TargetSupernodeAccount = "sn-other-target" }, + func(x *TranscriptInputs) { x.TicketID = "ticket-2" }, + func(x *TranscriptInputs) { x.Bucket = audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD }, + func(x *TranscriptInputs) { x.ArtifactOrdinal = 4 }, + func(x *TranscriptInputs) { x.ArtifactKey = "p2p-key-other" }, + func(x *TranscriptInputs) { x.DerivationInputHash = "00" }, + func(x *TranscriptInputs) { x.CompoundProofHashHex = "11" }, + func(x *TranscriptInputs) { x.ObserverIDs = []string{"sn-obs-3"} }, + } + for i, mut := range cases { + x := in + mut(&x) + got, _ := TranscriptHash(x) + if got == a { + t.Fatalf("mutation %d did not change transcript hash", i) + } + } +} + +func TestTranscriptHash_UnsupportedBucket(t *testing.T) { + in := TranscriptInputs{ + Bucket: audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED, + } + if _, err := TranscriptHash(in); err == nil { + t.Fatal("expected error for UNSPECIFIED bucket") + } +} + +func TestArtifactClassDomain_Stable(t *testing.T) { + cases := map[audittypes.StorageProofArtifactClass]string{ + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: "INDEX", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: "SYMBOL", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED: "", + } + for cls, want := range cases { + got := ArtifactClassDomain(cls) + if got != want { + t.Fatalf("ArtifactClassDomain(%v) = %q, want %q", cls, got, want) + } + } +} + +func TestBucketDomain_Stable(t *testing.T) { + cases := map[audittypes.StorageProofBucketType]string{ + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT: "RECENT", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD: "OLD", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_PROBATION: "PROBATION", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECHECK: "RECHECK", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED: "", + } + for b, want := range cases { + if got := BucketDomain(b); got != want { + t.Fatalf("BucketDomain(%v) = %q, want %q", b, got, want) + } + } +} + +// --- helpers ---------------------------------------------------------------- + +func equalSliceOrdered(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalSliceUint64(a, b []uint64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// ifmt is a tiny non-allocating int-to-decimal-string we use only inside +// distribution tests where strconv.Itoa would still be fine — kept minimal so +// the test file has no extra imports beyond the production package's. +func ifmt(i int) string { + if i == 0 { + return "0" + } + neg := i < 0 + if neg { + i = -i + } + var buf [20]byte + pos := len(buf) + for i > 0 { + pos-- + buf[pos] = byte('0' + i%10) + i /= 10 + } + if neg { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) +} + +// Stable cross-platform sort.Strings sanity check (catches accidental +// reliance on platform-specific stable-sort behaviour in CI). +func TestSortStrings_StableForPairs(t *testing.T) { + xs := []string{"sn-c", "sn-a", "sn-b"} + sort.Strings(xs) + want := []string{"sn-a", "sn-b", "sn-c"} + if !equalSliceOrdered(xs, want) { + t.Fatalf("stable sort mismatch: %v != %v", xs, want) + } +} + +// TestChainDefaults_BoundToSupernodeConstants is a chain-binding cross-validation +// guard: the supernode's deterministic primitives are a parallel implementation +// of chain logic, and any drift between supernode constants and chain defaults +// breaks consensus equivalence silently. This test imports the chain types +// package (already in go.mod via PR1) and asserts the supernode's hardcoded +// constants and the values consumed by SelectLEP6Targets/ClassifyTicketBucket +// match chain DefaultParams() byte-for-byte. +// +// Why this test belongs here (not in PR6 e2e): chain defaults are pure values +// reachable without an sdk.Context. A unit-level binding catches drift the +// instant chain bumps a default, before any integration env is even spun up. +// +// If chain ever renames or removes one of these symbols, this test will fail +// to compile — which is the desired loud-failure mode. +func TestChainDefaults_BoundToSupernodeConstants(t *testing.T) { + chain := audittypes.DefaultParams().WithDefaults() + + // 1. Challenge target divisor — drives SelectLEP6Targets count. + if got, want := uint32(LEP6ChallengeTargetDivisor), audittypes.DefaultStorageTruthChallengeTargetDivisor; got != want { + t.Fatalf("LEP6ChallengeTargetDivisor drift: supernode=%d chain=%d", got, want) + } + if got, want := chain.StorageTruthChallengeTargetDivisor, audittypes.DefaultStorageTruthChallengeTargetDivisor; got != want { + t.Fatalf("DefaultParams().StorageTruthChallengeTargetDivisor drift: %d vs %d", got, want) + } + + // 2. Recent-bucket window — drives ClassifyTicketBucket RECENT boundary. + if chain.StorageTruthRecentBucketMaxBlocks != audittypes.DefaultStorageTruthRecentBucketMaxBlocks { + t.Fatalf("DefaultStorageTruthRecentBucketMaxBlocks drift: params=%d const=%d", + chain.StorageTruthRecentBucketMaxBlocks, audittypes.DefaultStorageTruthRecentBucketMaxBlocks) + } + + // 3. Old-bucket window — drives ClassifyTicketBucket OLD boundary. + if chain.StorageTruthOldBucketMinBlocks != audittypes.DefaultStorageTruthOldBucketMinBlocks { + t.Fatalf("DefaultStorageTruthOldBucketMinBlocks drift: params=%d const=%d", + chain.StorageTruthOldBucketMinBlocks, audittypes.DefaultStorageTruthOldBucketMinBlocks) + } + + // 4. Old > Recent invariant — chain's bucket classification depends on + // OLD floor being strictly greater than RECENT ceiling. If governance + // ever flips this, supernode's ClassifyTicketBucket would silently + // misclassify all in-between heights. + if chain.StorageTruthOldBucketMinBlocks <= chain.StorageTruthRecentBucketMaxBlocks { + t.Fatalf("OLD floor must exceed RECENT ceiling: old=%d recent=%d", + chain.StorageTruthOldBucketMinBlocks, chain.StorageTruthRecentBucketMaxBlocks) + } + + // 5. End-to-end: drive SelectLEP6Targets with chain-sourced divisor on the + // chain test's exact fixture and confirm the same 2-target outcome the + // chain test asserts. Locks supernode→chain agreement to chain's own + // test vector, not just a self-generated one. + active := []string{"sn-a", "sn-b", "sn-c", "sn-d", "sn-e", "sn-f"} + tgt := SelectLEP6Targets(active, chainSeed, chain.StorageTruthChallengeTargetDivisor) + if len(tgt) != 2 { + t.Fatalf("chain-defaults end-to-end: want 2 targets per chain test, got %d (%v)", len(tgt), tgt) + } + + // 6. ClassifyTicketBucket sanity at chain-default boundaries: an action + // anchored exactly RECENT_MAX blocks behind current is RECENT; anchored + // OLD_MIN behind is OLD. Crosses both windows; locks bucket logic to + // chain defaults. + const currentH int64 = 1_000_000 + recentAnchor := currentH - int64(chain.StorageTruthRecentBucketMaxBlocks) // RECENT inclusive at boundary + oldAnchor := currentH - int64(chain.StorageTruthOldBucketMinBlocks) // OLD inclusive at boundary + if got := ClassifyTicketBucket(currentH, recentAnchor, chain.StorageTruthRecentBucketMaxBlocks, chain.StorageTruthOldBucketMinBlocks); got != audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT { + t.Fatalf("RECENT boundary classification drift: got %v at delta=%d", got, currentH-recentAnchor) + } + if got := ClassifyTicketBucket(currentH, oldAnchor, chain.StorageTruthRecentBucketMaxBlocks, chain.StorageTruthOldBucketMinBlocks); got != audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD { + t.Fatalf("OLD boundary classification drift: got %v at delta=%d", got, currentH-oldAnchor) + } +} diff --git a/pkg/storagechallenge/index_size_cache.go b/pkg/storagechallenge/index_size_cache.go new file mode 100644 index 00000000..c4705931 --- /dev/null +++ b/pkg/storagechallenge/index_size_cache.go @@ -0,0 +1,105 @@ +package storagechallenge + +import ( + "container/list" + "crypto/sha256" + "sync" +) + +// indexSizeCache memoises INDEX-class artifact byte lengths derived from +// cascadekit.GenerateIndexFiles, keyed by (signatures, rqIdsIc, rqIdsMax). +// +// LEP-6 review (Matee, 2026-05-06) — M4: ResolveArtifactSize for INDEX class +// previously re-ran cascadekit.GenerateIndexFiles on every dispatch (and on +// every recheck), regenerating the entire index file set just to read one +// ordinal's byte length. Two consequences: (a) hot-path CPU/RAM burn on the +// dispatcher; (b) the regenerated bytes are full files we never use. The +// cache stores only the per-ordinal byte length (uint64), bounded to 256 +// tickets via LRU eviction. The cache key is a SHA-256 digest over the +// concatenated (signatures, rqIdsIc, rqIdsMax) inputs to GenerateIndexFiles +// so two tickets with identical inputs collapse to one entry; collisions +// are not possible because the digest depends on every input GenerateIndexFiles +// reads. +type indexSizeCache struct { + mu sync.Mutex + cap int + order *list.List + items map[[32]byte]*list.Element +} + +type indexSizeEntry struct { + key [32]byte + sizes []uint64 +} + +const indexSizeCacheCap = 256 + +var globalIndexSizeCache = newIndexSizeCache(indexSizeCacheCap) + +func newIndexSizeCache(capacity int) *indexSizeCache { + if capacity <= 0 { + capacity = 1 + } + return &indexSizeCache{ + cap: capacity, + order: list.New(), + items: make(map[[32]byte]*list.Element, capacity), + } +} + +func indexSizeKey(signatures string, rqIdsIc uint32, rqIdsMax uint32) [32]byte { + h := sha256.New() + _, _ = h.Write([]byte(signatures)) + _, _ = h.Write([]byte{0}) + var buf [8]byte + buf[0] = byte(rqIdsIc >> 24) + buf[1] = byte(rqIdsIc >> 16) + buf[2] = byte(rqIdsIc >> 8) + buf[3] = byte(rqIdsIc) + buf[4] = byte(rqIdsMax >> 24) + buf[5] = byte(rqIdsMax >> 16) + buf[6] = byte(rqIdsMax >> 8) + buf[7] = byte(rqIdsMax) + _, _ = h.Write(buf[:]) + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +// get returns a per-ordinal size slice if cached, or nil if not. Updates LRU. +func (c *indexSizeCache) get(key [32]byte) []uint64 { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[key]; ok { + c.order.MoveToFront(el) + return el.Value.(*indexSizeEntry).sizes + } + return nil +} + +// put inserts sizes under key, evicting the LRU entry if full. +func (c *indexSizeCache) put(key [32]byte, sizes []uint64) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[key]; ok { + el.Value.(*indexSizeEntry).sizes = sizes + c.order.MoveToFront(el) + return + } + if c.order.Len() >= c.cap { + oldest := c.order.Back() + if oldest != nil { + delete(c.items, oldest.Value.(*indexSizeEntry).key) + c.order.Remove(oldest) + } + } + el := c.order.PushFront(&indexSizeEntry{key: key, sizes: sizes}) + c.items[key] = el +} + +// length returns the current cache occupancy. Test-only helper. +func (c *indexSizeCache) length() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.order.Len() +} diff --git a/pkg/storagechallenge/lep6_resolution.go b/pkg/storagechallenge/lep6_resolution.go new file mode 100644 index 00000000..604cf552 --- /dev/null +++ b/pkg/storagechallenge/lep6_resolution.go @@ -0,0 +1,174 @@ +// Package storagechallenge contains the supernode-side off-chain glue for the +// LEP-6 compound storage challenge runtime. The deterministic primitives that +// must agree byte-for-byte across reporters live in +// pkg/storagechallenge/deterministic; this file exposes the integration helpers +// that depend on cascade metadata and chain-side caps. +package storagechallenge + +import ( + "errors" + "fmt" + "math" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/cascadekit" +) + +// MaxStorageProofResultsPerReport mirrors the chain-side cap that the audit +// keeper enforces in DeliverTx for MsgSubmitEpochReport: an epoch report +// carrying more than this many StorageProofResults is rejected wholesale. +// +// Source of truth: lumera/x/audit/v1/types/keys.go (lines 11-13) at the +// pinned chain commit. The supernode result buffer must self-throttle to this +// cap before handing results to the host reporter — see +// supernode/storage_challenge/result_buffer.go. +const MaxStorageProofResultsPerReport = audittypes.MaxStorageProofResultsPerReport + +// ErrUnspecifiedArtifactClass is returned when a caller passes the zero/UNSPECIFIED +// StorageProofArtifactClass to a resolver that requires a concrete class. +var ErrUnspecifiedArtifactClass = errors.New("storagechallenge: artifact class is UNSPECIFIED") + +// ResolveArtifactCount returns the canonical artifact count for (meta, class) +// using only the cascade metadata that finalization committed on-chain. It +// replaces a chain GetTicketArtifactCount RPC that does not exist (LEP-6 v2 +// plan §9, Resolved Decision 8). +// +// Semantics: +// - INDEX -> uint32(meta.RqIdsIc) +// - SYMBOL -> uint32(len(meta.RqIdsIds)) +// - UNSPECIFIED -> error +// +// If both counts are zero (legacy / malformed ticket), this returns (0, nil) +// because the chain accepts that case via its TicketArtifactCountState fallback +// path (x/audit/v1/keeper/msg_submit_epoch_report_storage_proofs.go). Callers +// decide whether to skip such a ticket. +func ResolveArtifactCount(meta *actiontypes.CascadeMetadata, class audittypes.StorageProofArtifactClass) (uint32, error) { + if meta == nil { + return 0, errors.New("storagechallenge: nil cascade metadata") + } + switch class { + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: + return uint32(meta.RqIdsIc), nil + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: + return uint32(len(meta.RqIdsIds)), nil + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED: + return 0, ErrUnspecifiedArtifactClass + default: + return 0, fmt.Errorf("storagechallenge: unknown artifact class %v", class) + } +} + +// ResolveArtifactKey returns the deterministic artifact key (content-addressed +// identifier) for (meta, class, ordinal). +// +// - SYMBOL: meta.RqIdsIds[ordinal] (bounds-checked). +// - INDEX: derived via cascadekit.GenerateIndexIDs(meta.Signatures, RqIdsIc, +// RqIdsMax) — the same derivation the supernode cascade module uses to +// materialise INDEX files (supernode/cascade/helper.go, +// supernode/cascade/reseed.go). Per LEP-6 v2 plan §9 Resolved Decision 2. +// +// Returns an error on UNSPECIFIED class, ordinal out of range, or empty +// metadata for the requested class. +func ResolveArtifactKey(meta *actiontypes.CascadeMetadata, class audittypes.StorageProofArtifactClass, ordinal uint32) (string, error) { + if meta == nil { + return "", errors.New("storagechallenge: nil cascade metadata") + } + switch class { + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: + if int(ordinal) >= len(meta.RqIdsIds) { + return "", fmt.Errorf("storagechallenge: SYMBOL ordinal %d out of range (have %d ids)", ordinal, len(meta.RqIdsIds)) + } + return meta.RqIdsIds[ordinal], nil + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: + if meta.Signatures == "" { + return "", errors.New("storagechallenge: INDEX key requested but cascade metadata has empty signatures") + } + if meta.RqIdsMax == 0 { + return "", errors.New("storagechallenge: INDEX key requested but RqIdsMax is zero") + } + ids, err := cascadekit.GenerateIndexIDs(meta.Signatures, uint32(meta.RqIdsIc), uint32(meta.RqIdsMax)) + if err != nil { + return "", fmt.Errorf("storagechallenge: derive INDEX ids: %w", err) + } + if int(ordinal) >= len(ids) { + return "", fmt.Errorf("storagechallenge: INDEX ordinal %d out of range (derived %d ids)", ordinal, len(ids)) + } + return ids[ordinal], nil + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED: + return "", ErrUnspecifiedArtifactClass + default: + return "", fmt.Errorf("storagechallenge: unknown artifact class %v", class) + } +} + +// ResolveArtifactSize returns the exact byte size used to derive LEP-6 +// multi-range offsets for a selected artifact. +// +// SYMBOL artifacts are RaptorQ symbols. The exact symbol size is derived from +// the finalized Action.FileSizeKbs and meta.RqIdsMax: +// +// symbolSize = ceil(fileSizeKbs*1024 / meta.RqIdsMax) +// +// INDEX artifacts are generated deterministically from meta.Signatures, +// meta.RqIdsIc, and meta.RqIdsMax; their exact compressed byte length is the +// length of the selected generated index file. +func ResolveArtifactSize(act *actiontypes.Action, meta *actiontypes.CascadeMetadata, class audittypes.StorageProofArtifactClass, ordinal uint32) (uint64, error) { + if act == nil { + return 0, errors.New("storagechallenge: nil action") + } + if meta == nil { + return 0, errors.New("storagechallenge: nil cascade metadata") + } + switch class { + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: + if act.FileSizeKbs <= 0 { + return 0, fmt.Errorf("storagechallenge: action fileSizeKbs must be > 0 for SYMBOL artifact size (got %d)", act.FileSizeKbs) + } + if meta.RqIdsMax <= 0 { + return 0, errors.New("storagechallenge: RqIdsMax must be > 0 for SYMBOL artifact size") + } + if int(ordinal) >= len(meta.RqIdsIds) { + return 0, fmt.Errorf("storagechallenge: SYMBOL ordinal %d out of range (have %d ids)", ordinal, len(meta.RqIdsIds)) + } + fileBytes := uint64(act.FileSizeKbs) * 1024 + if fileBytes > math.MaxUint64-uint64(meta.RqIdsMax)+1 { + return 0, errors.New("storagechallenge: SYMBOL artifact size overflow") + } + return (fileBytes + uint64(meta.RqIdsMax) - 1) / uint64(meta.RqIdsMax), nil + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: + if meta.Signatures == "" { + return 0, errors.New("storagechallenge: INDEX size requested but cascade metadata has empty signatures") + } + if meta.RqIdsMax == 0 { + return 0, errors.New("storagechallenge: INDEX size requested but RqIdsMax is zero") + } + // LEP-6 review M4: avoid regenerating the full INDEX file set per + // dispatch — cache the per-ordinal sizes keyed by the deterministic + // inputs to GenerateIndexFiles. + cacheKey := indexSizeKey(meta.Signatures, uint32(meta.RqIdsIc), uint32(meta.RqIdsMax)) + if sizes := globalIndexSizeCache.get(cacheKey); sizes != nil { + if int(ordinal) >= len(sizes) { + return 0, fmt.Errorf("storagechallenge: INDEX ordinal %d out of range (cached %d sizes)", ordinal, len(sizes)) + } + return sizes[ordinal], nil + } + _, files, err := cascadekit.GenerateIndexFiles(meta.Signatures, uint32(meta.RqIdsIc), uint32(meta.RqIdsMax)) + if err != nil { + return 0, fmt.Errorf("storagechallenge: derive INDEX files: %w", err) + } + sizes := make([]uint64, len(files)) + for i := range files { + sizes[i] = uint64(len(files[i])) + } + globalIndexSizeCache.put(cacheKey, sizes) + if int(ordinal) >= len(sizes) { + return 0, fmt.Errorf("storagechallenge: INDEX ordinal %d out of range (derived %d files)", ordinal, len(sizes)) + } + return sizes[ordinal], nil + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED: + return 0, ErrUnspecifiedArtifactClass + default: + return 0, fmt.Errorf("storagechallenge: unknown artifact class %v", class) + } +} diff --git a/pkg/storagechallenge/lep6_resolution_test.go b/pkg/storagechallenge/lep6_resolution_test.go new file mode 100644 index 00000000..29d77908 --- /dev/null +++ b/pkg/storagechallenge/lep6_resolution_test.go @@ -0,0 +1,130 @@ +package storagechallenge + +import ( + "strings" + "testing" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +func TestMaxStorageProofResultsPerReportTracksChainConstant(t *testing.T) { + if MaxStorageProofResultsPerReport != audittypes.MaxStorageProofResultsPerReport { + t.Fatalf("MaxStorageProofResultsPerReport drifted from chain constant: got %d want %d", MaxStorageProofResultsPerReport, audittypes.MaxStorageProofResultsPerReport) + } +} + +func TestResolveArtifactCount_Index_Symbol_Unspecified(t *testing.T) { + meta := &actiontypes.CascadeMetadata{ + RqIdsIc: 7, + RqIdsMax: 12, + RqIdsIds: []string{"a", "b", "c", "d"}, + } + + gotIdx, err := ResolveArtifactCount(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX) + if err != nil { + t.Fatalf("INDEX: unexpected error: %v", err) + } + if gotIdx != 7 { + t.Fatalf("INDEX count: want 7, got %d", gotIdx) + } + + gotSym, err := ResolveArtifactCount(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL) + if err != nil { + t.Fatalf("SYMBOL: unexpected error: %v", err) + } + if gotSym != 4 { + t.Fatalf("SYMBOL count: want 4, got %d", gotSym) + } + + if _, err := ResolveArtifactCount(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED); err == nil { + t.Fatalf("UNSPECIFIED: expected error, got nil") + } +} + +func TestResolveArtifactCount_LegacyZero(t *testing.T) { + meta := &actiontypes.CascadeMetadata{} // both INDEX (RqIdsIc) and SYMBOL (len(RqIdsIds)) are zero + for _, class := range []audittypes.StorageProofArtifactClass{ + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, + } { + got, err := ResolveArtifactCount(meta, class) + if err != nil { + t.Fatalf("class=%v: legacy zero should not error, got: %v", class, err) + } + if got != 0 { + t.Fatalf("class=%v: want 0, got %d", class, got) + } + } +} + +func TestResolveArtifactKey_Symbol_OutOfRange(t *testing.T) { + meta := &actiontypes.CascadeMetadata{RqIdsIds: []string{"id-0", "id-1"}} + + got, err := ResolveArtifactKey(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 1) + if err != nil { + t.Fatalf("in-range SYMBOL: unexpected error: %v", err) + } + if got != "id-1" { + t.Fatalf("SYMBOL[1]: want id-1, got %q", got) + } + + if _, err := ResolveArtifactKey(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 2); err == nil { + t.Fatalf("SYMBOL[2]: expected out-of-range error, got nil") + } else if !strings.Contains(err.Error(), "out of range") { + t.Fatalf("SYMBOL[2]: error should mention out of range, got: %v", err) + } +} + +func TestResolveArtifactKey_Index_KnownVector(t *testing.T) { + meta := &actiontypes.CascadeMetadata{Signatures: "index-signature-format", RqIdsIc: 2, RqIdsMax: 5} + got0, err := ResolveArtifactKey(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, 0) + if err != nil { + t.Fatalf("INDEX[0]: unexpected error: %v", err) + } + got1, err := ResolveArtifactKey(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, 1) + if err != nil { + t.Fatalf("INDEX[1]: unexpected error: %v", err) + } + if got0 == "" || got1 == "" || got0 == got1 { + t.Fatalf("expected distinct non-empty index ids, got %q and %q", got0, got1) + } + if _, err := ResolveArtifactKey(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, 99); err == nil { + t.Fatalf("INDEX[99]: expected out-of-range error, got nil") + } +} + +func TestResolveArtifactSize_SymbolUsesCeilFileBytesOverRqMax(t *testing.T) { + act := &actiontypes.Action{FileSizeKbs: 10} + meta := &actiontypes.CascadeMetadata{RqIdsMax: 3, RqIdsIds: []string{"s0", "s1", "s2"}} + got, err := ResolveArtifactSize(act, meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 2) + if err != nil { + t.Fatalf("SYMBOL size: unexpected error: %v", err) + } + // ceil(10*1024 / 3) = 3414. + if got != 3414 { + t.Fatalf("SYMBOL size: want 3414, got %d", got) + } +} + +func TestResolveArtifactSize_IndexUsesGeneratedFileLength(t *testing.T) { + act := &actiontypes.Action{FileSizeKbs: 10} + meta := &actiontypes.CascadeMetadata{Signatures: "index-signature-format", RqIdsIc: 2, RqIdsMax: 5} + got, err := ResolveArtifactSize(act, meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, 1) + if err != nil { + t.Fatalf("INDEX size: unexpected error: %v", err) + } + if got == 0 { + t.Fatalf("INDEX size: expected non-zero generated file length") + } + if _, err := ResolveArtifactSize(act, meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, 99); err == nil { + t.Fatalf("INDEX[99]: expected out-of-range error, got nil") + } +} + +func TestResolveArtifactKey_Unspecified(t *testing.T) { + meta := &actiontypes.CascadeMetadata{} + if _, err := ResolveArtifactKey(meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, 0); err == nil { + t.Fatalf("UNSPECIFIED: expected error, got nil") + } +} diff --git a/pkg/storagechallenge/wave3_index_cache_test.go b/pkg/storagechallenge/wave3_index_cache_test.go new file mode 100644 index 00000000..f0f35af4 --- /dev/null +++ b/pkg/storagechallenge/wave3_index_cache_test.go @@ -0,0 +1,57 @@ +package storagechallenge + +import ( + "fmt" + "testing" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +// TestIndexSizeCache_M4_HitReturnsCachedSize pins LEP-6 review M4 (Matee): +// the second ResolveArtifactSize call for the same INDEX inputs must hit the +// cache (no regenerate). We assert this indirectly by populating the cache +// once and verifying the entry is reachable via globalIndexSizeCache.get with +// the same key. +func TestIndexSizeCache_M4_HitReturnsCachedSize(t *testing.T) { + act := &actiontypes.Action{FileSizeKbs: 10} + meta := &actiontypes.CascadeMetadata{Signatures: "index-signature-format", RqIdsIc: 2, RqIdsMax: 5} + + // Cold call → primes the cache. + first, err := ResolveArtifactSize(act, meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, 1) + if err != nil { + t.Fatalf("cold ResolveArtifactSize: %v", err) + } + + key := indexSizeKey(meta.Signatures, uint32(meta.RqIdsIc), uint32(meta.RqIdsMax)) + cached := globalIndexSizeCache.get(key) + if cached == nil { + t.Fatalf("M4 regression: cache must have an entry after cold call") + } + if int(1) >= len(cached) || cached[1] != first { + t.Fatalf("M4 cache content mismatch: cached[1]=%d, want %d", cached[1], first) + } + + // Warm call → returns same value. + second, err := ResolveArtifactSize(act, meta, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, 1) + if err != nil { + t.Fatalf("warm ResolveArtifactSize: %v", err) + } + if first != second { + t.Fatalf("M4 regression: cold=%d warm=%d (must match)", first, second) + } +} + +// TestIndexSizeCache_M4_LRUEvictionBoundedAt256 pins the cache cap so that a +// hot supernode handling thousands of distinct tickets does not unboundedly +// grow heap. +func TestIndexSizeCache_M4_LRUEvictionBoundedAt256(t *testing.T) { + c := newIndexSizeCache(indexSizeCacheCap) + for i := 0; i < indexSizeCacheCap*4; i++ { + key := indexSizeKey(fmt.Sprintf("sig-%d", i), 1, 1) + c.put(key, []uint64{uint64(i)}) + } + if got, want := c.length(), indexSizeCacheCap; got != want { + t.Fatalf("M4 LRU bound violated: length=%d, want=%d", got, want) + } +} diff --git a/pkg/testutil/lumera.go b/pkg/testutil/lumera.go index e6825c7f..9b20d5af 100644 --- a/pkg/testutil/lumera.go +++ b/pkg/testutil/lumera.go @@ -171,6 +171,10 @@ func (m *MockActionModule) GetParams(ctx context.Context) (*actiontypes.QueryPar return &actiontypes.QueryParamsResponse{}, nil } +func (m *MockActionModule) ListActionsBySuperNode(ctx context.Context, superNodeAddress string) (*actiontypes.QueryListActionsBySuperNodeResponse, error) { + return &actiontypes.QueryListActionsBySuperNodeResponse{}, nil +} + // MockActionMsgModule implements the action_msg.Module interface for testing type MockActionMsgModule struct{} @@ -218,13 +222,53 @@ func (m *MockAuditModule) GetEpochReport(ctx context.Context, epochID uint64, su return &audittypes.QueryEpochReportResponse{}, nil } +func (m *MockAuditModule) GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*audittypes.QueryEpochReportsByReporterResponse, error) { + return &audittypes.QueryEpochReportsByReporterResponse{}, nil +} + +func (m *MockAuditModule) GetNodeSuspicionState(ctx context.Context, supernodeAccount string) (*audittypes.QueryNodeSuspicionStateResponse, error) { + return &audittypes.QueryNodeSuspicionStateResponse{}, nil +} + +func (m *MockAuditModule) GetReporterReliabilityState(ctx context.Context, reporterAccount string) (*audittypes.QueryReporterReliabilityStateResponse, error) { + return &audittypes.QueryReporterReliabilityStateResponse{}, nil +} + +func (m *MockAuditModule) GetTicketDeteriorationState(ctx context.Context, ticketID string) (*audittypes.QueryTicketDeteriorationStateResponse, error) { + return &audittypes.QueryTicketDeteriorationStateResponse{}, nil +} + +func (m *MockAuditModule) GetHealOp(ctx context.Context, healOpID uint64) (*audittypes.QueryHealOpResponse, error) { + return &audittypes.QueryHealOpResponse{}, nil +} + +func (m *MockAuditModule) GetHealOpsByStatus(ctx context.Context, status audittypes.HealOpStatus, pagination *query.PageRequest) (*audittypes.QueryHealOpsByStatusResponse, error) { + return &audittypes.QueryHealOpsByStatusResponse{}, nil +} + +func (m *MockAuditModule) GetHealOpsByTicket(ctx context.Context, ticketID string, pagination *query.PageRequest) (*audittypes.QueryHealOpsByTicketResponse, error) { + return &audittypes.QueryHealOpsByTicketResponse{}, nil +} + type MockAuditMsgModule struct{} func (m *MockAuditMsgModule) SubmitEvidence(ctx context.Context, subjectAddress string, evidenceType audittypes.EvidenceType, actionID string, metadataJSON string) (*sdktx.BroadcastTxResponse, error) { return &sdktx.BroadcastTxResponse{}, nil } -func (m *MockAuditMsgModule) SubmitEpochReport(ctx context.Context, epochID uint64, hostReport audittypes.HostReport, storageChallengeObservations []*audittypes.StorageChallengeObservation) (*sdktx.BroadcastTxResponse, error) { +func (m *MockAuditMsgModule) SubmitEpochReport(ctx context.Context, epochID uint64, hostReport audittypes.HostReport, storageChallengeObservations []*audittypes.StorageChallengeObservation, storageProofResults []*audittypes.StorageProofResult) (*sdktx.BroadcastTxResponse, error) { + return &sdktx.BroadcastTxResponse{}, nil +} + +func (m *MockAuditMsgModule) SubmitStorageRecheckEvidence(ctx context.Context, epochID uint64, challengedSupernodeAccount string, ticketID string, challengedResultTranscriptHash string, recheckTranscriptHash string, recheckResultClass audittypes.StorageProofResultClass, details string) (*sdktx.BroadcastTxResponse, error) { + return &sdktx.BroadcastTxResponse{}, nil +} + +func (m *MockAuditMsgModule) ClaimHealComplete(ctx context.Context, healOpID uint64, ticketID string, healManifestHash string, details string) (*sdktx.BroadcastTxResponse, error) { + return &sdktx.BroadcastTxResponse{}, nil +} + +func (m *MockAuditMsgModule) SubmitHealVerification(ctx context.Context, healOpID uint64, verified bool, verificationHash string, details string) (*sdktx.BroadcastTxResponse, error) { return &sdktx.BroadcastTxResponse{}, nil } diff --git a/proto/supernode/self_healing.proto b/proto/supernode/self_healing.proto new file mode 100644 index 00000000..9af6373a --- /dev/null +++ b/proto/supernode/self_healing.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; +package supernode; +option go_package = "github.com/LumeraProtocol/supernode/v2/gen/supernode"; + +// SelfHealingService — LEP-6 §19 healer-served path. +// +// LEP-6 prescribes a strict three-phase heal flow: +// Phase 1 (RECONSTRUCT) — assigned healer reconstructs the file locally and +// submits MsgClaimHealComplete{HealManifestHash}. Artefacts are STAGED +// locally; they are NOT yet published to KAD. +// Phase 2 (VERIFY) — assigned verifiers MUST fetch the reconstructed bytes +// directly from the assigned healer (this RPC), not from KAD, because the +// healer-served path is the only authority before chain VERIFIED. +// Phase 3 (PUBLISH) — only after chain reaches VERIFIED quorum the healer's +// finalizer publishes staged artefacts to KAD via the same store path +// register/upload uses. +// +// This service exposes only the verifier-side fetch — chain coordinates role +// assignment and quorum, so the legacy peer Request/Verify/Commit RPCs are +// gone in the LEP-6 model. +service SelfHealingService { + // ServeReconstructedArtefacts streams the reconstructed file bytes to an + // authorized verifier. The handler MUST verify caller ∈ + // op.VerifierSupernodeAccounts before serving any bytes. + rpc ServeReconstructedArtefacts(ServeReconstructedArtefactsRequest) + returns (stream ServeReconstructedArtefactsResponse) {} +} + +message ServeReconstructedArtefactsRequest { + // heal_op_id identifies the heal-op the caller is verifying. Server uses it + // to look up the staging dir and authorize the caller against + // op.VerifierSupernodeAccounts. + uint64 heal_op_id = 1; + // verifier_account is the caller's chain-side supernode account address. + // Server cross-checks against authenticated grpc identity AND against + // op.VerifierSupernodeAccounts. + string verifier_account = 2; +} + +message ServeReconstructedArtefactsResponse { + // chunk is a contiguous slice of the reconstructed file bytes. Chunks are + // ordered; concatenating chunks across the stream yields the original file + // whose BLAKE3 must equal op.ResultHash (= action.DataHash recipe). + bytes chunk = 1; + // total_size is the full file size; populated on the first message and + // optionally repeated. Allows clients to pre-allocate buffers. + uint64 total_size = 2; + // is_last indicates this message carries the final chunk. + bool is_last = 3; +} diff --git a/proto/supernode/status.proto b/proto/supernode/status.proto index d944d614..f78fe087 100644 --- a/proto/supernode/status.proto +++ b/proto/supernode/status.proto @@ -129,4 +129,37 @@ message StatusResponse { } P2PMetrics p2p_metrics = 9; + + // LEP-6 storage-truth runtime metrics and diagnostics. These are in-memory + // counters/gauges reset on process restart, matching the existing typed + // status-snapshot pattern used for P2P metrics. + message LEP6Metrics { + // Storage challenge / dispatch signals. + map dispatch_results_total = 1; + map dispatch_throttled_total = 2; + map dispatch_epoch_duration_millis_total = 3; + map dispatch_epoch_duration_millis_max = 4; + map dispatch_epoch_duration_count = 5; + map ticket_discovery_total = 6; + int64 no_ticket_provider_active = 7; + + // Self-healing signals. + map heal_claims_submitted_total = 8; + uint64 heal_claims_reconciled_total = 9; + map heal_verifications_submitted_total = 10; + uint64 heal_verifications_already_exists_total = 11; + uint64 heal_finalize_publishes_total = 12; + map heal_finalize_cleanups_total = 13; + int64 self_healing_pending_claims = 14; + int64 self_healing_staging_bytes = 15; + + // Storage recheck signals. + uint64 recheck_candidates_found_total = 16; + map recheck_evidence_submitted_total = 17; + uint64 recheck_evidence_already_submitted_total = 18; + map recheck_execution_failures_total = 19; + int64 recheck_pending_candidates = 20; + } + + LEP6Metrics lep6_metrics = 10; } diff --git a/proto/supernode/storage_challenge.proto b/proto/supernode/storage_challenge.proto index 6494787c..60b0c7ac 100644 --- a/proto/supernode/storage_challenge.proto +++ b/proto/supernode/storage_challenge.proto @@ -6,6 +6,45 @@ option go_package = "github.com/LumeraProtocol/supernode/v2/gen/supernode"; service StorageChallengeService { rpc GetSliceProof(GetSliceProofRequest) returns (GetSliceProofResponse) {} rpc VerifySliceProof(VerifySliceProofRequest) returns (VerifySliceProofResponse) {} + rpc GetCompoundProof(GetCompoundProofRequest) returns (GetCompoundProofResponse) {} +} + +// ByteRange represents a half-open byte range [start, end) into an artifact. +message ByteRange { + uint64 start = 1; + uint64 end = 2; // exclusive +} + +message GetCompoundProofRequest { + string challenge_id = 1; + uint64 epoch_id = 2; + bytes seed = 3; + string ticket_id = 4; + string target_supernode_account = 5; + string challenger_account = 6; + repeated string observer_accounts = 7; + uint32 artifact_class = 8; // mirrors audittypes.StorageProofArtifactClass + uint32 artifact_ordinal = 9; + uint32 artifact_count = 10; + uint32 bucket_type = 11; // mirrors audittypes.StorageProofBucketType + string artifact_key = 12; + uint64 artifact_size = 13; + repeated ByteRange ranges = 14; // exactly LEP6CompoundRangesPerArtifact (=4); each size LEP6CompoundRangeLenBytes (=256) +} + +message GetCompoundProofResponse { + string challenge_id = 1; + uint64 epoch_id = 2; + string ticket_id = 3; + uint32 artifact_class = 4; + uint32 artifact_ordinal = 5; + uint32 bucket_type = 6; + string artifact_key = 7; + repeated bytes range_bytes = 8; // i-th matches i-th request range + string proof_hash_hex = 9; // BLAKE3(concat(range_bytes...)) lowercase hex + string recipient_signature = 10; // recipient's keyring signature + bool ok = 11; + string error = 12; } message GetSliceProofRequest { diff --git a/supernode/cascade/interfaces.go b/supernode/cascade/interfaces.go index 5a4d0d4e..7069b4e3 100644 --- a/supernode/cascade/interfaces.go +++ b/supernode/cascade/interfaces.go @@ -16,4 +16,11 @@ type CascadeTask interface { Register(ctx context.Context, req *RegisterRequest, send func(resp *RegisterResponse) error) error Download(ctx context.Context, req *DownloadRequest, send func(resp *DownloadResponse) error) error CleanupDownload(ctx context.Context, tmpDir string) error + + // LEP-6 healer entrypoints. Surface RecoveryReseed and the staged-publish + // promotion so the self_healing service can consume the cascade pipeline + // through CascadeServiceFactory without depending on the concrete + // *CascadeRegistrationTask. + RecoveryReseed(ctx context.Context, req *RecoveryReseedRequest) (*RecoveryReseedResult, error) + PublishStagedArtefacts(ctx context.Context, stagingDir string) error } diff --git a/supernode/cascade/reseed.go b/supernode/cascade/reseed.go index 3cdd9a26..9db451b6 100644 --- a/supernode/cascade/reseed.go +++ b/supernode/cascade/reseed.go @@ -2,7 +2,12 @@ package cascade import ( "context" + "encoding/base64" + "encoding/json" "fmt" + "io" + "os" + "path/filepath" "sort" "strings" @@ -12,8 +17,16 @@ import ( "github.com/LumeraProtocol/supernode/v2/pkg/utils" ) +// RecoveryReseedRequest carries the inputs for an end-to-end LEP-6 heal +// reconstruction. When PersistArtifacts is true (legacy / register-equivalent +// behavior) the rebuilt artefacts are stored to KAD via the same store path +// register/upload uses. When PersistArtifacts is false (LEP-6 §19 healer-served +// path) the artefacts are STAGED to StagingDir and not published; a later +// PublishStagedArtefacts call performs the KAD store after chain VERIFIED. type RecoveryReseedRequest struct { - ActionID string + ActionID string + PersistArtifacts bool // false = stage only (LEP-6 default); true = publish to KAD + StagingDir string // required when PersistArtifacts=false } type RecoveryReseedResult struct { @@ -31,11 +44,44 @@ type RecoveryReseedResult struct { LayoutFilesGenerated int IDFilesGenerated int SymbolsGenerated int + // StagingDir is set when artefacts were staged rather than published. + StagingDir string + // ReconstructedFilePath is the local path of the decoded original file. + // Caller is responsible for cleanup; on staged paths this is informational. + ReconstructedFilePath string + // ReconstructedHashB64 is the base64-encoded BLAKE3 of the reconstructed + // file (= action.DataHash recipe; LEP-6 HealManifestHash). + ReconstructedHashB64 string } +// stagedManifest is the on-disk descriptor written into a heal-op staging dir +// so a later PublishStagedArtefacts() call can reconstruct the storeArtefacts +// inputs without re-running download/decode/encode. +type stagedManifest struct { + ActionID string `json:"action_id"` + Layout codec.Layout `json:"layout"` + IDFiles []string `json:"id_files"` // base64 of idFile bytes + SymbolKeys []string `json:"symbol_keys"` // ordered, deduped + SymbolsDir string `json:"symbols_dir"` // absolute path inside StagingDir/symbols + ReconstructedRel string `json:"reconstructed_rel"` // staging-dir-relative path of the reconstructed file + ManifestHashB64 string `json:"manifest_hash_b64"` // = action.DataHash recipe; HealManifestHash +} + +const stagedManifestFilename = "manifest.json" +const stagedSymbolsDirname = "symbols" +const stagedIDFilesDirname = "id_files" +const stagedReconstructedFilename = "reconstructed.bin" + // RecoveryReseed decodes an existing action, re-encodes the reconstructed file, -// regenerates RQ artefacts with the action's original RQ params, and stores -// them via the same store path used by register. +// regenerates RQ artefacts with the action's original RQ params, and either +// stages them to disk (LEP-6 healer flow, PersistArtifacts=false) or stores +// them via the same store path used by register (legacy / republish flow, +// PersistArtifacts=true). +// +// LEP-6 §19 mandates the healer-served path: heal-op artefacts MUST NOT enter +// KAD until the chain has reached VERIFIED quorum, otherwise verifiers could +// fetch from KAD before the healer's hash is attested. PR-4 finalizer calls +// PublishStagedArtefacts only after observing op.Status == VERIFIED. func (task *CascadeRegistrationTask) RecoveryReseed(ctx context.Context, req *RecoveryReseedRequest) (*RecoveryReseedResult, error) { if req == nil { return nil, fmt.Errorf("missing request") @@ -44,9 +90,12 @@ func (task *CascadeRegistrationTask) RecoveryReseed(ctx context.Context, req *Re if actionID == "" { return nil, fmt.Errorf("missing action_id") } + if !req.PersistArtifacts && strings.TrimSpace(req.StagingDir) == "" { + return nil, fmt.Errorf("staging_dir required when persist_artifacts=false") + } task.taskID = actionID - fields := logtrace.Fields{logtrace.FieldMethod: "RecoveryReseed", logtrace.FieldActionID: actionID} + fields := logtrace.Fields{logtrace.FieldMethod: "RecoveryReseed", logtrace.FieldActionID: actionID, "persist_artifacts": req.PersistArtifacts} action, err := task.fetchAction(ctx, actionID, fields) if err != nil { @@ -115,6 +164,11 @@ func (task *CascadeRegistrationTask) RecoveryReseed(ctx context.Context, req *Re return result, task.wrapErr(ctx, "decoded file hash does not match action metadata", err, fields) } result.DataHashVerified = true + result.ReconstructedFilePath = decodeFilePath + // HealManifestHash = base64(BLAKE3(reconstructed_file)) — same recipe as + // Action.DataHash (cascadekit.ComputeBlake3DataHashB64). meta.DataHash is + // already that exact string, and VerifyB64DataHash above proved equality. + result.ReconstructedHashB64 = strings.TrimSpace(meta.DataHash) encodeResult, err := task.encodeInput(ctx, actionID, decodeFilePath, fields) if err != nil { @@ -128,8 +182,16 @@ func (task *CascadeRegistrationTask) RecoveryReseed(ctx context.Context, req *Re if err != nil { return result, err } - if err := task.storeArtefacts(ctx, action.ActionID, idFiles, encodeResult.SymbolsDir, encodeResult.Layout, fields); err != nil { - return result, err + + if req.PersistArtifacts { + if err := task.storeArtefacts(ctx, action.ActionID, idFiles, encodeResult.SymbolsDir, encodeResult.Layout, fields); err != nil { + return result, err + } + } else { + if err := task.stageArtefacts(ctx, req.StagingDir, action.ActionID, idFiles, encodeResult.SymbolsDir, encodeResult.Layout, decodeFilePath, result.ReconstructedHashB64, fields); err != nil { + return result, err + } + result.StagingDir = req.StagingDir } result.IndexIDs = indexIDs @@ -143,6 +205,107 @@ func (task *CascadeRegistrationTask) RecoveryReseed(ctx context.Context, req *Re return result, nil } +// stageArtefacts copies the encoded symbols + idFiles + layout + the +// reconstructed file into stagingDir, writing a manifest the finalizer reads +// when publishing and the §19 transport reads when serving verifiers. +// stagingDir is the per-heal-op directory (e.g. +// ~/.supernode/heal-staging//). +func (task *CascadeRegistrationTask) stageArtefacts(ctx context.Context, stagingDir, actionID string, idFiles [][]byte, symbolsDir string, layout codec.Layout, reconstructedFilePath, manifestHashB64 string, f logtrace.Fields) error { + if f == nil { + f = logtrace.Fields{} + } + lf := logtrace.Fields{logtrace.FieldActionID: actionID, logtrace.FieldTaskID: task.taskID, "staging_dir": stagingDir, "id_files_count": len(idFiles)} + for k, v := range f { + lf[k] = v + } + if err := os.MkdirAll(stagingDir, 0o700); err != nil { + return task.wrapErr(ctx, "failed to create staging dir", err, lf) + } + stagedSymbols := filepath.Join(stagingDir, stagedSymbolsDirname) + if err := os.MkdirAll(stagedSymbols, 0o700); err != nil { + return task.wrapErr(ctx, "failed to create staged symbols dir", err, lf) + } + if err := copyDirContents(symbolsDir, stagedSymbols); err != nil { + return task.wrapErr(ctx, "failed to copy symbols into staging dir", err, lf) + } + stagedIDDir := filepath.Join(stagingDir, stagedIDFilesDirname) + if err := os.MkdirAll(stagedIDDir, 0o700); err != nil { + return task.wrapErr(ctx, "failed to create staged id_files dir", err, lf) + } + idFilesEncoded := make([]string, 0, len(idFiles)) + for i, b := range idFiles { + // Persist raw bytes for fidelity; encode to base64 in manifest for + // portability across filesystems / observation. + path := filepath.Join(stagedIDDir, fmt.Sprintf("idfile_%05d.bin", i)) + if err := os.WriteFile(path, b, 0o600); err != nil { + return task.wrapErr(ctx, "failed to write staged id file", err, lf) + } + idFilesEncoded = append(idFilesEncoded, base64.StdEncoding.EncodeToString(b)) + } + manifest := stagedManifest{ + ActionID: actionID, + Layout: layout, + IDFiles: idFilesEncoded, + SymbolKeys: symbolIDsFromLayout(layout), + SymbolsDir: stagedSymbols, + ReconstructedRel: stagedReconstructedFilename, + ManifestHashB64: manifestHashB64, + } + // Stage the reconstructed file bytes so the §19 healer-served-path + // transport can stream them to verifiers without re-running download + + // decode. M5 fix: stream via io.Copy instead of loading the whole file + // into RAM (peak RAM was 2 × file_size at MaxConcurrentReconstructs=2). + if strings.TrimSpace(reconstructedFilePath) != "" { + if err := streamCopyFile(reconstructedFilePath, filepath.Join(stagingDir, stagedReconstructedFilename)); err != nil { + return task.wrapErr(ctx, "failed to stage reconstructed file", err, lf) + } + } + manifestPath := filepath.Join(stagingDir, stagedManifestFilename) + mb, err := json.Marshal(manifest) + if err != nil { + return task.wrapErr(ctx, "failed to marshal staged manifest", err, lf) + } + if err := os.WriteFile(manifestPath, mb, 0o600); err != nil { + return task.wrapErr(ctx, "failed to write staged manifest", err, lf) + } + logtrace.Info(ctx, "stage: artefacts staged", lf) + return nil +} + +// PublishStagedArtefacts reads a stagingDir produced by stageArtefacts and +// performs the KAD store via the same store path register/upload uses. Called +// by the LEP-6 finalizer after the chain reports HealOp.Status == VERIFIED. +func (task *CascadeRegistrationTask) PublishStagedArtefacts(ctx context.Context, stagingDir string) error { + stagingDir = strings.TrimSpace(stagingDir) + if stagingDir == "" { + return fmt.Errorf("missing staging_dir") + } + manifestPath := filepath.Join(stagingDir, stagedManifestFilename) + mb, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("read staged manifest: %w", err) + } + var manifest stagedManifest + if err := json.Unmarshal(mb, &manifest); err != nil { + return fmt.Errorf("parse staged manifest: %w", err) + } + idFiles := make([][]byte, 0, len(manifest.IDFiles)) + for i, enc := range manifest.IDFiles { + b, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return fmt.Errorf("decode id_file[%d]: %w", i, err) + } + idFiles = append(idFiles, b) + } + task.taskID = manifest.ActionID + fields := logtrace.Fields{ + logtrace.FieldMethod: "PublishStagedArtefacts", + logtrace.FieldActionID: manifest.ActionID, + "staging_dir": stagingDir, + } + return task.storeArtefacts(ctx, manifest.ActionID, idFiles, manifest.SymbolsDir, manifest.Layout, fields) +} + func symbolIDsFromLayout(layout codec.Layout) []string { seen := make(map[string]struct{}, 1024) for _, block := range layout.Blocks { @@ -161,3 +324,57 @@ func symbolIDsFromLayout(layout codec.Layout) []string { sort.Strings(out) return out } + +func copyDirContents(srcDir, dstDir string) error { + entries, err := os.ReadDir(srcDir) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() { + // symbols layout is flat; recurse defensively + if err := os.MkdirAll(filepath.Join(dstDir, e.Name()), 0o700); err != nil { + return err + } + if err := copyDirContents(filepath.Join(srcDir, e.Name()), filepath.Join(dstDir, e.Name())); err != nil { + return err + } + continue + } + if err := streamCopyFile(filepath.Join(srcDir, e.Name()), filepath.Join(dstDir, e.Name())); err != nil { + return err + } + } + return nil +} + +// streamCopyFile copies src→dst via io.Copy, avoiding the os.ReadFile+ +// os.WriteFile pattern that loads the entire file into RAM. Used by the +// LEP-6 §19 staging path where reconstructed files can be multi-GB and +// the healer runs MaxConcurrentReconstructs in parallel. dst is created +// 0o600 to match the existing staging permission convention. +// +// Wave 2 / M5 fix. +func streamCopyFile(srcPath, dstPath string) error { + src, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("open src %q: %w", srcPath, err) + } + defer src.Close() + dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("open dst %q: %w", dstPath, err) + } + if _, err := io.Copy(dst, src); err != nil { + _ = dst.Close() + return fmt.Errorf("copy %q → %q: %w", srcPath, dstPath, err) + } + if err := dst.Sync(); err != nil { + _ = dst.Close() + return fmt.Errorf("sync dst %q: %w", dstPath, err) + } + if err := dst.Close(); err != nil { + return fmt.Errorf("close dst %q: %w", dstPath, err) + } + return nil +} diff --git a/supernode/cascade/reseed_wave3_test.go b/supernode/cascade/reseed_wave3_test.go new file mode 100644 index 00000000..889fa7ab --- /dev/null +++ b/supernode/cascade/reseed_wave3_test.go @@ -0,0 +1,26 @@ +package cascade + +import ( + "os" + "strings" + "testing" +) + +func TestStreamCopyFileSyncsBeforeClose(t *testing.T) { + src, err := os.ReadFile("reseed.go") + if err != nil { + t.Fatal(err) + } + body := string(src) + syncIdx := strings.Index(body, "dst.Sync()") + closeIdx := strings.LastIndex(body, "dst.Close()") + if syncIdx < 0 { + t.Fatalf("streamCopyFile must fsync destination before close") + } + if closeIdx < 0 { + t.Fatalf("streamCopyFile close call not found") + } + if syncIdx > closeIdx { + t.Fatalf("streamCopyFile must call dst.Sync before final dst.Close") + } +} diff --git a/supernode/cascade/staged.go b/supernode/cascade/staged.go new file mode 100644 index 00000000..05d5f6c7 --- /dev/null +++ b/supernode/cascade/staged.go @@ -0,0 +1,42 @@ +package cascade + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// StagedHealOpInfo is the public projection of stagedManifest used by the +// LEP-6 §19 healer-served transport (supernode/transport/grpc/self_healing). +type StagedHealOpInfo struct { + ActionID string + ReconstructedFilePath string + ManifestHashB64 string +} + +// ReadStagedHealOp loads the manifest from a heal-op staging directory and +// returns the absolute reconstructed-file path the §19 transport streams to +// verifiers, plus the manifest hash for cross-checks. Returns os.ErrNotExist +// (wrapped) when the staging dir or its manifest is missing — caller may +// treat that as "not yet staged" and respond NotFound to the gRPC client. +func ReadStagedHealOp(stagingDir string) (StagedHealOpInfo, error) { + manifestPath := filepath.Join(stagingDir, stagedManifestFilename) + mb, err := os.ReadFile(manifestPath) + if err != nil { + return StagedHealOpInfo{}, fmt.Errorf("read staged manifest %q: %w", manifestPath, err) + } + var m stagedManifest + if err := json.Unmarshal(mb, &m); err != nil { + return StagedHealOpInfo{}, fmt.Errorf("parse staged manifest %q: %w", manifestPath, err) + } + rel := m.ReconstructedRel + if rel == "" { + rel = stagedReconstructedFilename + } + return StagedHealOpInfo{ + ActionID: m.ActionID, + ReconstructedFilePath: filepath.Join(stagingDir, rel), + ManifestHashB64: m.ManifestHashB64, + }, nil +} diff --git a/supernode/cascade/wave2_streamcopy_test.go b/supernode/cascade/wave2_streamcopy_test.go new file mode 100644 index 00000000..fac7b510 --- /dev/null +++ b/supernode/cascade/wave2_streamcopy_test.go @@ -0,0 +1,60 @@ +package cascade + +import ( + "crypto/sha256" + "os" + "path/filepath" + "testing" +) + +// TestStreamCopyFile_PreservesContents covers the M5 fix: the streaming +// copy must produce a byte-for-byte identical file (we replaced the +// os.ReadFile + os.WriteFile RAM-blowup pattern with io.Copy). +func TestStreamCopyFile_PreservesContents(t *testing.T) { + tmp := t.TempDir() + src := filepath.Join(tmp, "src.bin") + dst := filepath.Join(tmp, "dst.bin") + + // Generate a non-trivial payload spanning multiple internal buffer + // flushes (default io.Copy buffer is 32 KiB). + body := make([]byte, 256*1024) // 256 KiB + for i := range body { + body[i] = byte(i % 251) + } + if err := os.WriteFile(src, body, 0o600); err != nil { + t.Fatalf("write src: %v", err) + } + + if err := streamCopyFile(src, dst); err != nil { + t.Fatalf("streamCopyFile: %v", err) + } + + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read dst: %v", err) + } + if len(got) != len(body) { + t.Fatalf("copied length %d; want %d", len(got), len(body)) + } + if sha256.Sum256(got) != sha256.Sum256(body) { + t.Fatalf("copied bytes differ from src") + } + // Permissions match staging convention. + st, err := os.Stat(dst) + if err != nil { + t.Fatalf("stat dst: %v", err) + } + if got, want := st.Mode().Perm(), os.FileMode(0o600); got != want { + t.Fatalf("dst perm = %o; want %o", got, want) + } +} + +// TestStreamCopyFile_SrcMissing covers error propagation: a missing src +// file must surface as an error rather than silently producing an empty +// dst. +func TestStreamCopyFile_SrcMissing(t *testing.T) { + tmp := t.TempDir() + if err := streamCopyFile(filepath.Join(tmp, "nope"), filepath.Join(tmp, "out")); err == nil { + t.Fatalf("expected error when src is missing") + } +} diff --git a/supernode/cmd/helpers.go b/supernode/cmd/helpers.go index 0d51bc45..7cf612c3 100644 --- a/supernode/cmd/helpers.go +++ b/supernode/cmd/helpers.go @@ -63,9 +63,10 @@ func isValidBIP39WordCount(wordCount int) bool { // createP2PConfig creates a P2P config from the app config and address func createP2PConfig(config *config.Config, address string) *p2p.Config { return &p2p.Config{ - ListenAddress: config.SupernodeConfig.Host, - Port: config.P2PConfig.Port, - DataDir: config.GetP2PDataDir(), - ID: address, + ListenAddress: config.SupernodeConfig.Host, + Port: config.P2PConfig.Port, + DataDir: config.GetP2PDataDir(), + BootstrapNodes: config.P2PConfig.BootstrapNodes, + ID: address, } } diff --git a/supernode/cmd/lep6_adapters.go b/supernode/cmd/lep6_adapters.go new file mode 100644 index 00000000..19e8984b --- /dev/null +++ b/supernode/cmd/lep6_adapters.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "context" + "fmt" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/p2p" + "github.com/LumeraProtocol/supernode/v2/pkg/cascadekit" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" +) + +// p2pArtifactReader is the recipient-side adapter that satisfies the +// transport/grpc/storage_challenge ArtifactReader interface by retrieving +// the full artifact bytes from the local p2p store and slicing the +// requested range. The PR3 path is correct-but-not-optimal: a future +// iteration can replace this with a range-scoped reader. +type p2pArtifactReader struct { + p2p p2p.P2P +} + +func newP2PArtifactReader(p p2p.P2P) *p2pArtifactReader { + return &p2pArtifactReader{p2p: p} +} + +// ReadArtifactRange returns bytes [start, end) for the given key. class is +// currently informational; storage is content-addressed by key alone. +func (r *p2pArtifactReader) ReadArtifactRange(ctx context.Context, _ audittypes.StorageProofArtifactClass, key string, start, end uint64) ([]byte, error) { + if r == nil || r.p2p == nil { + return nil, fmt.Errorf("p2pArtifactReader: nil p2p service") + } + if end <= start { + return nil, fmt.Errorf("p2pArtifactReader: invalid range [%d,%d)", start, end) + } + data, err := r.p2p.Retrieve(ctx, key, true) + if err != nil { + return nil, err + } + if uint64(len(data)) < end { + return nil, fmt.Errorf("p2pArtifactReader: range [%d,%d) out of bounds (size=%d)", start, end, len(data)) + } + out := make([]byte, end-start) + copy(out, data[start:end]) + return out, nil +} + +// cascadeMetaProvider implements storage_challenge.CascadeMetaProvider via +// the lumera Action module. It fetches the on-chain action, decodes its +// CascadeMetadata, and returns it alongside the finalized action FileSizeKbs +// needed for exact artifact-size derivation. +type cascadeMetaProvider struct { + client lumera.Client +} + +func newCascadeMetaProvider(c lumera.Client) *cascadeMetaProvider { + return &cascadeMetaProvider{client: c} +} + +func (m *cascadeMetaProvider) GetCascadeMetadata(ctx context.Context, ticketID string) (*actiontypes.CascadeMetadata, uint64, error) { + if m == nil || m.client == nil || m.client.Action() == nil { + return nil, 0, fmt.Errorf("cascadeMetaProvider: nil action module") + } + resp, err := m.client.Action().GetAction(ctx, ticketID) + if err != nil || resp == nil { + return nil, 0, fmt.Errorf("get action %q: %w", ticketID, err) + } + act := resp.GetAction() + if act == nil { + return nil, 0, fmt.Errorf("get action %q: nil action", ticketID) + } + meta, err := cascadekit.UnmarshalCascadeMetadata(act.Metadata) + if err != nil { + return nil, 0, fmt.Errorf("decode cascade metadata for %q: %w", ticketID, err) + } + return &meta, uint64(act.FileSizeKbs), nil +} diff --git a/supernode/cmd/start.go b/supernode/cmd/start.go index 3bed95d3..799e9267 100644 --- a/supernode/cmd/start.go +++ b/supernode/cmd/start.go @@ -24,6 +24,8 @@ import ( cascadeService "github.com/LumeraProtocol/supernode/v2/supernode/cascade" "github.com/LumeraProtocol/supernode/v2/supernode/config" hostReporterService "github.com/LumeraProtocol/supernode/v2/supernode/host_reporter" + recheckService "github.com/LumeraProtocol/supernode/v2/supernode/recheck" + selfHealingService "github.com/LumeraProtocol/supernode/v2/supernode/self_healing" statusService "github.com/LumeraProtocol/supernode/v2/supernode/status" storageChallengeService "github.com/LumeraProtocol/supernode/v2/supernode/storage_challenge" // Legacy supernode metrics reporter (MsgReportSupernodeMetrics) has been superseded by @@ -31,6 +33,7 @@ import ( // supernodeMetrics "github.com/LumeraProtocol/supernode/v2/supernode/supernode_metrics" "github.com/LumeraProtocol/supernode/v2/supernode/transport/gateway" cascadeRPC "github.com/LumeraProtocol/supernode/v2/supernode/transport/grpc/cascade" + selfHealingRPC "github.com/LumeraProtocol/supernode/v2/supernode/transport/grpc/self_healing" server "github.com/LumeraProtocol/supernode/v2/supernode/transport/grpc/status" storageChallengeRPC "github.com/LumeraProtocol/supernode/v2/supernode/transport/grpc/storage_challenge" "github.com/LumeraProtocol/supernode/v2/supernode/verifier" @@ -73,6 +76,19 @@ The supernode will connect to the Lumera network and begin participating in the cfgFile := filepath.Join(baseDir, DefaultConfigFile) logtrace.Debug(ctx, "Starting supernode with configuration", logtrace.Fields{"config_file": cfgFile, "keyring_dir": appConfig.GetKeyringDir(), "key_name": appConfig.SupernodeConfig.KeyName}) + // LEP-6 review C1 (Matee, 2026-05-06): the LEP-6 toggles default to + // FALSE on missing-block. If this operator upgraded without adding + // the toggles, surface a WARN so they can see they are out of policy + // before the chain enforcement mode flips to SOFT/FULL. Empty string + // means everything is opted in. + if advisory := appConfig.LEP6OperatorOptInAdvisory(); advisory != "" { + logtrace.Warn(ctx, advisory, logtrace.Fields{ + "storage_challenge.lep6.enabled": appConfig.StorageChallengeConfig.LEP6.Enabled, + "storage_challenge.lep6.recheck.enabled": appConfig.StorageChallengeConfig.LEP6.Recheck.Enabled, + "self_healing.enabled": appConfig.SelfHealingConfig.Enabled, + }) + } + // Initialize keyring kr, err := initKeyringFromConfig(appConfig) if err != nil { @@ -169,7 +185,7 @@ The supernode will connect to the Lumera network and begin participating in the // race against the SN's own ~5s auto-submit ticker. Production deployments must // leave this unset; gated behind an env var with no config-file surface so the // canonical path is unchanged. - var hostReporter service + var hostReporter *hostReporterService.Service if v := strings.TrimSpace(os.Getenv("LUMERA_SUPERNODE_DISABLE_HOST_REPORTER")); v == "1" || strings.EqualFold(v, "true") { logtrace.Info(ctx, "host_reporter disabled via LUMERA_SUPERNODE_DISABLE_HOST_REPORTER", logtrace.Fields{}) } else { @@ -201,13 +217,24 @@ The supernode will connect to the Lumera network and begin participating in the // logtrace.Info(ctx, "Metrics collection enabled", logtrace.Fields{}) // Storage challenge history DB (shared by the gRPC handler and runner). - historyStore, err := queries.OpenHistoryDB() + historyStore, err := queries.OpenHistoryDBAt(appConfig.BaseDir) if err != nil { logtrace.Fatal(ctx, "Failed to open history DB", logtrace.Fields{"error": err.Error()}) } - storageChallengeServer := storageChallengeRPC.NewServer(appConfig.SupernodeConfig.Identity, p2pService, historyStore) + // LEP-6 result buffer: drained by host_reporter on each tick and + // appended to by the LEP6Dispatcher. + resultBuffer := storageChallengeService.NewBuffer() + if hostReporter != nil { + hostReporter.SetProofResultProvider(resultBuffer) + } + + storageChallengeServer := storageChallengeRPC.NewServer(appConfig.SupernodeConfig.Identity, p2pService, historyStore). + WithArtifactReader(newP2PArtifactReader(p2pService)). + WithRecipientSigner(kr, appConfig.SupernodeConfig.KeyName). + WithAuditParams(lumeraClient.Audit()) var storageChallengeRunner *storageChallengeService.Service + var recheckRunner *recheckService.Service if appConfig.StorageChallengeConfig.Enabled { storageChallengeRunner, err = storageChallengeService.NewService( appConfig.SupernodeConfig.Identity, @@ -226,12 +253,113 @@ The supernode will connect to the Lumera network and begin participating in the if err != nil { logtrace.Fatal(ctx, "Failed to initialize storage challenge runner", logtrace.Fields{"error": err.Error()}) } + + // LEP-6 dispatcher (mode-gated internally; see DispatchEpoch). + if appConfig.StorageChallengeConfig.LEP6.Enabled { + dispatcher, derr := storageChallengeService.NewLEP6Dispatcher( + lumeraClient, + kr, + appConfig.SupernodeConfig.KeyName, + appConfig.SupernodeConfig.Identity, + storageChallengeService.NewSecureSupernodeClientFactory(lumeraClient, kr, appConfig.SupernodeConfig.Identity, appConfig.SupernodeConfig.Port), + storageChallengeService.NewChainTicketProvider(lumeraClient), + newCascadeMetaProvider(lumeraClient), + resultBuffer, + ) + if derr != nil { + logtrace.Fatal(ctx, "Failed to initialize LEP-6 dispatcher", logtrace.Fields{"error": derr.Error()}) + } + storageChallengeRunner.SetLEP6Dispatcher(dispatcher) + + if appConfig.StorageChallengeConfig.LEP6.Recheck.Enabled { + rc := appConfig.StorageChallengeConfig.LEP6.Recheck + tickInterval := time.Duration(rc.TickIntervalMs) * time.Millisecond + failureBackoffTTL := time.Duration(rc.FailureBackoffTTLms) * time.Millisecond + recheckCfg := recheckService.Config{ + Enabled: true, + LookbackEpochs: rc.LookbackEpochs, + MaxPerTick: rc.MaxPerTick, + TickInterval: tickInterval, + MaxFailureAttemptsPerTicket: rc.MaxFailureAttemptsPerTicket, + FailureBackoffTTL: failureBackoffTTL, + } + attestor := recheckService.NewAttestor(appConfig.SupernodeConfig.Identity, lumeraClient.AuditMsg(), historyStore) + reporterSource := recheckService.NewSupernodeReporterSource(lumeraClient.SuperNode(), appConfig.SupernodeConfig.Identity) + recheckRunner, err = recheckService.NewServiceWithReporters(recheckCfg, lumeraClient.Audit(), historyStore, dispatcher, attestor, appConfig.SupernodeConfig.Identity, reporterSource) + if err != nil { + logtrace.Fatal(ctx, "Failed to initialize LEP-6 recheck runner", logtrace.Fields{"error": err.Error()}) + } + } + } } // Create supernode server supernodeServer := server.NewSupernodeServer(statusSvc) + // LEP-6 self-healing runtime (chain-driven heal-op dispatch). + // The dispatcher polls audit heal-ops and runs healer/verifier/ + // finalizer roles based on chain assignment. The §19 transport + // server lets verifiers fetch reconstructed bytes from the + // assigned healer before chain VERIFIED quorum. + var selfHealingRunner *selfHealingService.Service + var selfHealingServer *selfHealingRPC.Server + if appConfig.SelfHealingConfig.Enabled { + pollInterval := time.Duration(appConfig.SelfHealingConfig.PollIntervalMs) * time.Millisecond + fetchTimeout := time.Duration(appConfig.SelfHealingConfig.VerifierFetchTimeoutMs) * time.Millisecond + // LEP-6 review M1 (Matee, 2026-05-06): the configured staging + // dir may be relative (default "heal-staging"). Resolve it + // against appConfig.BaseDir so we don't end up writing to the + // process's working directory (e.g. "/heal-staging" if launched + // from "/"). + stagingRoot := appConfig.GetFullPath(appConfig.SelfHealingConfig.StagingDir) + shCfg := selfHealingService.Config{ + Enabled: true, + PollInterval: pollInterval, + MaxConcurrentReconstructs: appConfig.SelfHealingConfig.MaxConcurrentReconstructs, + MaxConcurrentVerifications: appConfig.SelfHealingConfig.MaxConcurrentVerifications, + MaxConcurrentPublishes: appConfig.SelfHealingConfig.MaxConcurrentPublishes, + StagingRoot: stagingRoot, + VerifierFetchTimeout: fetchTimeout, + VerifierFetchAttempts: appConfig.SelfHealingConfig.VerifierFetchAttempts, + VerifierBackoffBase: time.Duration(appConfig.SelfHealingConfig.VerifierBackoffBaseMs) * time.Millisecond, + AuditQueryTimeout: time.Duration(appConfig.SelfHealingConfig.AuditQueryTimeoutMs) * time.Millisecond, + KeyName: appConfig.SupernodeConfig.KeyName, + } + fetcher := selfHealingService.NewSecureVerifierFetcher(lumeraClient, kr, appConfig.SupernodeConfig.Identity, appConfig.SupernodeConfig.Port) + selfHealingRunner, err = selfHealingService.New( + appConfig.SupernodeConfig.Identity, + shCfg, + lumeraClient, + historyStore, + cService, + fetcher, + ) + if err != nil { + logtrace.Fatal(ctx, "Failed to initialize self-healing runner", logtrace.Fields{"error": err.Error()}) + } + selfHealingServer, err = selfHealingRPC.NewServer( + appConfig.SupernodeConfig.Identity, + shCfg.StagingRoot, + lumeraClient, + selfHealingRPC.DefaultCallerIdentityResolver(), + ) + if err != nil { + logtrace.Fatal(ctx, "Failed to initialize self-healing transport", logtrace.Fields{"error": err.Error()}) + } + } + // Create gRPC server (explicit args, no config struct) + grpcServices := []grpcserver.ServiceDesc{ + {Desc: &pbcascade.CascadeService_ServiceDesc, Service: cascadeActionServer}, + {Desc: &pbsupernode.SupernodeService_ServiceDesc, Service: supernodeServer}, + {Desc: &pbsupernode.StorageChallengeService_ServiceDesc, Service: storageChallengeServer}, + } + if selfHealingServer != nil { + grpcServices = append(grpcServices, grpcserver.ServiceDesc{ + Desc: &pbsupernode.SelfHealingService_ServiceDesc, + Service: selfHealingServer, + }) + } grpcServer, err := server.New( appConfig.SupernodeConfig.Identity, appConfig.SupernodeConfig.Host, @@ -239,9 +367,7 @@ The supernode will connect to the Lumera network and begin participating in the "service", kr, lumeraClient, - grpcserver.ServiceDesc{Desc: &pbcascade.CascadeService_ServiceDesc, Service: cascadeActionServer}, - grpcserver.ServiceDesc{Desc: &pbsupernode.SupernodeService_ServiceDesc, Service: supernodeServer}, - grpcserver.ServiceDesc{Desc: &pbsupernode.StorageChallengeService_ServiceDesc, Service: storageChallengeServer}, + grpcServices..., ) if err != nil { logtrace.Fatal(ctx, "Failed to create gRPC server", logtrace.Fields{"error": err.Error()}) @@ -274,6 +400,12 @@ The supernode will connect to the Lumera network and begin participating in the if storageChallengeRunner != nil { services = append(services, storageChallengeRunner) } + if selfHealingRunner != nil { + services = append(services, selfHealingRunner) + } + if recheckRunner != nil { + services = append(services, recheckRunner) + } servicesErr <- RunServices(ctx, services...) }() @@ -310,7 +442,13 @@ The supernode will connect to the Lumera network and begin participating in the } }() grpcServer.Close() - historyStore.CloseHistoryDB(context.Background()) + + // LEP-6 review M3 (Matee, 2026-05-06): historyStore.CloseHistoryDB + // MUST run AFTER all services have drained — otherwise late writes + // from heal-claim / heal-verification finalisers race against the + // closing SQLite handle and silently lose state. We close the DB + // here only on the services-exited path; the signal path waits for + // drain below before closing. // Close Lumera client without blocking shutdown logtrace.Debug(ctx, "Closing Lumera client", logtrace.Fields{}) @@ -321,12 +459,20 @@ The supernode will connect to the Lumera network and begin participating in the }() // If we triggered shutdown by signal, wait for services to drain + // BEFORE closing the history store. Self-healing finalisers can + // emit one final dedup-row update on shutdown; closing the DB + // before they finish loses that state. if triggeredBySignal { if err := <-servicesErr; err != nil { logtrace.Error(ctx, "Service error on shutdown", logtrace.Fields{"error": err.Error()}) } } + // Now safe to close the history store — services have drained on + // both shutdown paths (services-exited path: <-servicesErr already + // fired; signal path: just waited above). + historyStore.CloseHistoryDB(context.Background()) + return nil }, } diff --git a/supernode/config.yml b/supernode/config.yml index 350650e2..a4c2b89d 100644 --- a/supernode/config.yml +++ b/supernode/config.yml @@ -32,3 +32,31 @@ storage_challenge: enabled: true poll_interval_ms: 5000 submit_evidence: true + lep6: + # Local challenger toggle only. Chain audit params remain the global + # protocol gate; STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED disables + # LEP-6 behavior even when this is true. + enabled: true + max_concurrent_targets: 4 + recipient_read_timeout: 30s + recheck: + enabled: true + lookback_epochs: 7 + max_per_tick: 5 + tick_interval_ms: 60000 + max_failure_attempts_per_ticket: 3 + failure_backoff_ttl_ms: 900000 + +# LEP-6 Self-Healing Configuration +self_healing: + # Local healer/verifier/finalizer toggle only; chain mode remains the + # global protocol gate. + enabled: true + poll_interval_ms: 30000 + max_concurrent_reconstructs: 2 + max_concurrent_verifications: 4 + max_concurrent_publishes: 2 + staging_dir: "heal-staging" + verifier_fetch_timeout_ms: 60000 + verifier_fetch_attempts: 3 + verifier_backoff_base_ms: 2000 diff --git a/supernode/config/config.go b/supernode/config/config.go index 16dc3c6f..ed3177b1 100644 --- a/supernode/config/config.go +++ b/supernode/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" "gopkg.in/yaml.v3" @@ -30,8 +31,9 @@ type KeyringConfig struct { } type P2PConfig struct { - Port uint16 `yaml:"port"` - DataDir string `yaml:"data_dir"` + Port uint16 `yaml:"port"` + DataDir string `yaml:"data_dir"` + BootstrapNodes string `yaml:"bootstrap_nodes,omitempty"` } type LumeraClientConfig struct { @@ -66,9 +68,88 @@ type LogConfig struct { } type StorageChallengeConfig struct { + Enabled bool `yaml:"enabled"` + PollIntervalMs uint64 `yaml:"poll_interval_ms,omitempty"` + SubmitEvidence bool `yaml:"submit_evidence,omitempty"` + LEP6 StorageChallengeLEP6Config `yaml:"lep6,omitempty"` +} + +// StorageChallengeLEP6Config holds the supernode-binary-owned knobs for +// the LEP-6 compound storage challenge runtime. All chain-driven knobs +// (bucket thresholds, ranges-per-artifact, range size, enforcement mode) +// flow via x/audit Params and are deliberately omitted here. See +// docs/plans/LEP6_SUPERNODE_IMPLEMENTATION_PLAN_v2.md §2.3. +type StorageChallengeLEP6Config struct { + // enabledSet tracks whether YAML explicitly provided enabled. Plain bools + // cannot distinguish omitted from explicit false, but LEP-6 needs both safe + // default-on local toggles and emergency-disable `enabled: false`. + enabledSet bool `yaml:"-"` + + // Enabled gates construction of the LEP6Dispatcher. When false, the + // legacy single-range loop runs alone (default true; the chain audit + // StorageTruthEnforcementMode remains the protocol source of truth). + Enabled bool `yaml:"enabled"` + // MaxConcurrentTargets bounds parallelism inside DispatchEpoch. + // Default 4. Reserved for follow-up parallelism work; PR3 dispatch + // is currently sequential per target. + MaxConcurrentTargets int `yaml:"max_concurrent_targets,omitempty"` + // RecipientReadTimeout caps a single GetCompoundProof RPC. Default + // 30s. + RecipientReadTimeout time.Duration `yaml:"recipient_read_timeout,omitempty"` + // Recheck owns the PR-5 storage-truth recheck evidence submitter. + Recheck StorageRecheckConfig `yaml:"recheck,omitempty"` +} + +type StorageRecheckConfig struct { + enabledSet bool `yaml:"-"` + Enabled bool `yaml:"enabled"` - PollIntervalMs uint64 `yaml:"poll_interval_ms,omitempty"` - SubmitEvidence bool `yaml:"submit_evidence,omitempty"` + LookbackEpochs uint64 `yaml:"lookback_epochs,omitempty"` + MaxPerTick int `yaml:"max_per_tick,omitempty"` + TickIntervalMs int `yaml:"tick_interval_ms,omitempty"` + // MaxFailureAttemptsPerTicket bounds repeated failed recheck attempts for + // one epoch/ticket before the candidate is temporarily skipped. + MaxFailureAttemptsPerTicket int `yaml:"max_failure_attempts_per_ticket,omitempty"` + // FailureBackoffTTLms is the TTL for recorded recheck attempt failures. + FailureBackoffTTLms int `yaml:"failure_backoff_ttl_ms,omitempty"` +} + +// SelfHealingConfig configures the LEP-6 chain-driven self-healing runtime +// (supernode/self_healing). Mode gating is also enforced at runtime via +// the chain's StorageTruthEnforcementMode param — UNSPECIFIED skips the +// dispatcher regardless of Enabled. +type SelfHealingConfig struct { + // enabledSet tracks explicit YAML emergency-disable vs omitted default. + enabledSet bool `yaml:"-"` + + // Enabled toggles the dispatcher and the §19 transport server. Default + // true; chain StorageTruthEnforcementMode=UNSPECIFIED remains the global + // protocol disable. + Enabled bool `yaml:"enabled"` + // PollIntervalMs is the dispatcher tick cadence (default 30000). + PollIntervalMs int `yaml:"poll_interval_ms,omitempty"` + // MaxConcurrentReconstructs bounds RaptorQ reseeds (RAM-heavy). + // Default 2. + MaxConcurrentReconstructs int `yaml:"max_concurrent_reconstructs,omitempty"` + // MaxConcurrentVerifications bounds verifier fetch+hash workers. + // Default 4. + MaxConcurrentVerifications int `yaml:"max_concurrent_verifications,omitempty"` + // MaxConcurrentPublishes bounds publish-to-KAD workers. Default 2. + MaxConcurrentPublishes int `yaml:"max_concurrent_publishes,omitempty"` + // StagingDir is the local staging root (default ~/.supernode/heal-staging). + StagingDir string `yaml:"staging_dir,omitempty"` + // VerifierFetchTimeoutMs caps a single ServeReconstructedArtefacts + // stream from healer (default 60000). + VerifierFetchTimeoutMs int `yaml:"verifier_fetch_timeout_ms,omitempty"` + // VerifierFetchAttempts bounds retries when fetching from healer + // (default 3). + VerifierFetchAttempts int `yaml:"verifier_fetch_attempts,omitempty"` + // VerifierBackoffBaseMs is the exponential retry backoff base between + // healer fetch attempts (default 2000). + VerifierBackoffBaseMs int `yaml:"verifier_backoff_base_ms,omitempty"` + // AuditQueryTimeoutMs bounds each dispatcher chain query so one wedged + // status/params call cannot starve verifier/finalizer work (default 10000). + AuditQueryTimeoutMs int `yaml:"audit_query_timeout_ms,omitempty"` } type Config struct { @@ -78,6 +159,7 @@ type Config struct { LumeraClientConfig `yaml:"lumera"` RaptorQConfig `yaml:"raptorq"` StorageChallengeConfig `yaml:"storage_challenge"` + SelfHealingConfig `yaml:"self_healing"` // Store base directory (not from YAML) BaseDir string `yaml:"-"` @@ -171,6 +253,9 @@ func LoadConfig(filename string, baseDir string) (*Config, error) { if config.StorageChallengeConfig.PollIntervalMs == 0 { config.StorageChallengeConfig.PollIntervalMs = DefaultStorageChallengePollIntervalMs } + if err := config.applyLEP6DefaultsAndValidate(); err != nil { + return nil, err + } // Create directories if err := config.EnsureDirs(); err != nil { diff --git a/supernode/config/config_lep6_test.go b/supernode/config/config_lep6_test.go new file mode 100644 index 00000000..6e580371 --- /dev/null +++ b/supernode/config/config_lep6_test.go @@ -0,0 +1,259 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadConfig_LEP6SafeDefaults(t *testing.T) { + t.Parallel() + + // LEP-6 review C1 (Matee, 2026-05-06): with the missing-block default + // flipped to FALSE, an operator who upgrades without adding the LEP-6 + // toggles MUST stay opted out. This test pins that contract: even + // though storage_challenge.enabled=true, the missing lep6 / recheck / + // self_healing blocks default to disabled. Operators must opt in + // explicitly. Runtime knobs (timeouts, concurrency) still receive + // their defaults so that flipping a toggle on later requires no + // further config edits. + cfg := loadConfigFromBody(t, ` +supernode: + key_name: test-key + identity: lumera1identity000000000000000000000000000000 + host: 0.0.0.0 + port: 4444 +keyring: + backend: test + dir: keys +p2p: + port: 4445 + data_dir: data/p2p +lumera: + grpc_addr: localhost:9090 + chain_id: testing +raptorq: + files_dir: raptorq_files +storage_challenge: + enabled: true +`) + + if cfg.StorageChallengeConfig.LEP6.Enabled { + t.Fatalf("storage_challenge.lep6.enabled default = true, want false (C1: opt-in not opt-out)") + } + if cfg.StorageChallengeConfig.LEP6.MaxConcurrentTargets != DefaultLEP6MaxConcurrentTargets { + t.Fatalf("max_concurrent_targets = %d, want %d", cfg.StorageChallengeConfig.LEP6.MaxConcurrentTargets, DefaultLEP6MaxConcurrentTargets) + } + if cfg.StorageChallengeConfig.LEP6.RecipientReadTimeout != DefaultLEP6RecipientReadTimeout { + t.Fatalf("recipient_read_timeout = %s, want %s", cfg.StorageChallengeConfig.LEP6.RecipientReadTimeout, DefaultLEP6RecipientReadTimeout) + } + if cfg.StorageChallengeConfig.LEP6.Recheck.Enabled { + t.Fatalf("storage_challenge.lep6.recheck.enabled default = true, want false (C1)") + } + if cfg.StorageChallengeConfig.LEP6.Recheck.LookbackEpochs != DefaultLEP6RecheckLookbackEpochs { + t.Fatalf("recheck.lookback_epochs = %d, want %d", cfg.StorageChallengeConfig.LEP6.Recheck.LookbackEpochs, DefaultLEP6RecheckLookbackEpochs) + } + if cfg.StorageChallengeConfig.LEP6.Recheck.MaxPerTick != DefaultLEP6RecheckMaxPerTick { + t.Fatalf("recheck.max_per_tick = %d, want %d", cfg.StorageChallengeConfig.LEP6.Recheck.MaxPerTick, DefaultLEP6RecheckMaxPerTick) + } + if cfg.StorageChallengeConfig.LEP6.Recheck.TickIntervalMs != int(DefaultLEP6RecheckTickInterval/time.Millisecond) { + t.Fatalf("recheck.tick_interval_ms = %d, want %d", cfg.StorageChallengeConfig.LEP6.Recheck.TickIntervalMs, int(DefaultLEP6RecheckTickInterval/time.Millisecond)) + } + if cfg.StorageChallengeConfig.LEP6.Recheck.MaxFailureAttemptsPerTicket != DefaultLEP6RecheckMaxFailureAttemptsPerTicket { + t.Fatalf("recheck.max_failure_attempts_per_ticket = %d, want %d", cfg.StorageChallengeConfig.LEP6.Recheck.MaxFailureAttemptsPerTicket, DefaultLEP6RecheckMaxFailureAttemptsPerTicket) + } + if cfg.StorageChallengeConfig.LEP6.Recheck.FailureBackoffTTLms != int(DefaultLEP6RecheckFailureBackoffTTL/time.Millisecond) { + t.Fatalf("recheck.failure_backoff_ttl_ms = %d, want %d", cfg.StorageChallengeConfig.LEP6.Recheck.FailureBackoffTTLms, int(DefaultLEP6RecheckFailureBackoffTTL/time.Millisecond)) + } + + if cfg.SelfHealingConfig.Enabled { + t.Fatalf("self_healing.enabled default = true, want false (C1)") + } + if cfg.SelfHealingConfig.PollIntervalMs != int(DefaultSelfHealingPollInterval/time.Millisecond) { + t.Fatalf("self_healing.poll_interval_ms = %d, want %d", cfg.SelfHealingConfig.PollIntervalMs, int(DefaultSelfHealingPollInterval/time.Millisecond)) + } + if cfg.SelfHealingConfig.MaxConcurrentReconstructs != DefaultSelfHealingMaxConcurrentReconstructs { + t.Fatalf("self_healing.max_concurrent_reconstructs = %d, want %d", cfg.SelfHealingConfig.MaxConcurrentReconstructs, DefaultSelfHealingMaxConcurrentReconstructs) + } + if cfg.SelfHealingConfig.MaxConcurrentVerifications != DefaultSelfHealingMaxConcurrentVerifications { + t.Fatalf("self_healing.max_concurrent_verifications = %d, want %d", cfg.SelfHealingConfig.MaxConcurrentVerifications, DefaultSelfHealingMaxConcurrentVerifications) + } + if cfg.SelfHealingConfig.MaxConcurrentPublishes != DefaultSelfHealingMaxConcurrentPublishes { + t.Fatalf("self_healing.max_concurrent_publishes = %d, want %d", cfg.SelfHealingConfig.MaxConcurrentPublishes, DefaultSelfHealingMaxConcurrentPublishes) + } + if cfg.SelfHealingConfig.StagingDir != DefaultSelfHealingStagingDir { + t.Fatalf("self_healing.staging_dir = %q, want %q", cfg.SelfHealingConfig.StagingDir, DefaultSelfHealingStagingDir) + } + if cfg.SelfHealingConfig.VerifierFetchTimeoutMs != int(DefaultSelfHealingVerifierFetchTimeout/time.Millisecond) { + t.Fatalf("self_healing.verifier_fetch_timeout_ms = %d, want %d", cfg.SelfHealingConfig.VerifierFetchTimeoutMs, int(DefaultSelfHealingVerifierFetchTimeout/time.Millisecond)) + } + if cfg.SelfHealingConfig.VerifierFetchAttempts != DefaultSelfHealingVerifierFetchAttempts { + t.Fatalf("self_healing.verifier_fetch_attempts = %d, want %d", cfg.SelfHealingConfig.VerifierFetchAttempts, DefaultSelfHealingVerifierFetchAttempts) + } + if cfg.SelfHealingConfig.VerifierBackoffBaseMs != int(DefaultSelfHealingVerifierBackoffBase/time.Millisecond) { + t.Fatalf("self_healing.verifier_backoff_base_ms = %d, want %d", cfg.SelfHealingConfig.VerifierBackoffBaseMs, int(DefaultSelfHealingVerifierBackoffBase/time.Millisecond)) + } +} + +func TestLoadConfig_LEP6EmergencyDisablesRemainFalse(t *testing.T) { + t.Parallel() + + cfg := loadConfigFromBody(t, ` +supernode: + key_name: test-key + identity: lumera1identity000000000000000000000000000000 + host: 0.0.0.0 + port: 4444 +keyring: + backend: test + dir: keys +p2p: + port: 4445 + data_dir: data/p2p +lumera: + grpc_addr: localhost:9090 + chain_id: testing +raptorq: + files_dir: raptorq_files +storage_challenge: + enabled: true + lep6: + enabled: false + recheck: + enabled: false +self_healing: + enabled: false +`) + + if cfg.StorageChallengeConfig.LEP6.Enabled { + t.Fatalf("storage_challenge.lep6.enabled = true, want explicit false emergency disable preserved") + } + if cfg.StorageChallengeConfig.LEP6.Recheck.Enabled { + t.Fatalf("storage_challenge.lep6.recheck.enabled = true, want explicit false emergency disable preserved") + } + if cfg.SelfHealingConfig.Enabled { + t.Fatalf("self_healing.enabled = true, want explicit false emergency disable preserved") + } +} + +func TestLoadConfig_LEP6InvalidNegativeKnobsRejected(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "dispatcher-targets": "storage_challenge:\n enabled: true\n lep6:\n max_concurrent_targets: -1\n", + "dispatcher-timeout": "storage_challenge:\n enabled: true\n lep6:\n recipient_read_timeout: -1s\n", + "recheck-max": "storage_challenge:\n enabled: true\n lep6:\n recheck:\n max_per_tick: -1\n", + "recheck-ttl": "storage_challenge:\n enabled: true\n lep6:\n recheck:\n failure_backoff_ttl_ms: -1\n", + "healing-poll": "storage_challenge:\n enabled: true\nself_healing:\n poll_interval_ms: -1\n", + "healing-backoff": "storage_challenge:\n enabled: true\nself_healing:\n verifier_backoff_base_ms: -1\n", + } + + for name, override := range cases { + name, override := name, override + t.Run(name, func(t *testing.T) { + t.Parallel() + body := baseConfigYAML() + override + dir := t.TempDir() + path := filepath.Join(dir, "supernode.yml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := LoadConfig(path, dir) + if err == nil { + t.Fatalf("LoadConfig succeeded, want validation error") + } + if !strings.Contains(err.Error(), "LEP-6") { + t.Fatalf("error = %v, want LEP-6 validation context", err) + } + }) + } +} + +func TestCreateDefaultConfig_IncludesExplicitLEP6Blocks(t *testing.T) { + t.Parallel() + + cfg := CreateDefaultConfig("test-key", "lumera1identity", "testing", "test", "keys", "", "", "") + if !cfg.StorageChallengeConfig.LEP6.Enabled || !cfg.StorageChallengeConfig.LEP6.Recheck.Enabled || !cfg.SelfHealingConfig.Enabled { + t.Fatalf("default config should explicitly include enabled LEP-6 local toggles behind chain mode gate: %+v", cfg) + } + if cfg.SelfHealingConfig.StagingDir == "" { + t.Fatalf("default config missing self_healing.staging_dir") + } +} + +func TestSystemConfigFixturesIncludeLEP6(t *testing.T) { + t.Parallel() + + fixtures := []string{ + "../../tests/system/config.lep6-1.yml", + "../../tests/system/config.lep6-2.yml", + "../../tests/system/config.lep6-3.yml", + } + for _, fixture := range fixtures { + fixture := fixture + t.Run(filepath.Base(fixture), func(t *testing.T) { + t.Parallel() + raw, err := os.ReadFile(fixture) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + body := string(raw) + for _, want := range []string{"storage_challenge:", "lep6:", "recheck:", "self_healing:"} { + if !strings.Contains(body, want) { + t.Fatalf("fixture %s missing %q", fixture, want) + } + } + cfg, err := LoadConfig(fixture, t.TempDir()) + if err != nil { + t.Fatalf("LoadConfig(%s): %v", fixture, err) + } + // LEP-6 review L6 (Matee, 2026-05-06): these fixtures keep the + // storage-challenge dispatcher OFF (e2e submits reports manually) + // and self-healing ON. Recheck must follow its parent gates per + // the new structural validator. + if cfg.SelfHealingConfig.Enabled == false { + t.Fatalf("fixture should enable self-healing runtime: %+v", cfg) + } + if cfg.StorageChallengeConfig.LEP6.Recheck.Enabled && !cfg.StorageChallengeConfig.LEP6.Enabled { + t.Fatalf("L6: fixture has recheck.enabled=true while lep6.enabled=false — invalid combination, would be rejected by validator") + } + }) + } +} + +func loadConfigFromBody(t *testing.T, body string) *Config { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "supernode.yml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + cfg, err := LoadConfig(path, dir) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + return cfg +} + +func baseConfigYAML() string { + return ` +supernode: + key_name: test-key + identity: lumera1identity000000000000000000000000000000 + host: 0.0.0.0 + port: 4444 +keyring: + backend: test + dir: keys +p2p: + port: 4445 + data_dir: data/p2p +lumera: + grpc_addr: localhost:9090 + chain_id: testing +raptorq: + files_dir: raptorq_files +` +} diff --git a/supernode/config/defaults.go b/supernode/config/defaults.go index e2cbedc7..1f30fdd3 100644 --- a/supernode/config/defaults.go +++ b/supernode/config/defaults.go @@ -1,5 +1,7 @@ package config +import "time" + // Centralized default values for configuration const ( @@ -13,4 +15,23 @@ const ( DefaultChainID = "testing" DefaultRaptorQFilesDir = "raptorq_files" DefaultStorageChallengePollIntervalMs = 5000 + + DefaultLEP6MaxConcurrentTargets = 4 + DefaultLEP6RecipientReadTimeout = 30 * time.Second + + DefaultLEP6RecheckLookbackEpochs = uint64(7) + DefaultLEP6RecheckMaxPerTick = 5 + DefaultLEP6RecheckTickInterval = time.Minute + DefaultLEP6RecheckMaxFailureAttemptsPerTicket = 3 + DefaultLEP6RecheckFailureBackoffTTL = 15 * time.Minute + + DefaultSelfHealingPollInterval = 30 * time.Second + DefaultSelfHealingMaxConcurrentReconstructs = 2 + DefaultSelfHealingMaxConcurrentVerifications = 4 + DefaultSelfHealingMaxConcurrentPublishes = 2 + DefaultSelfHealingStagingDir = "heal-staging" + DefaultSelfHealingVerifierFetchTimeout = 60 * time.Second + DefaultSelfHealingVerifierFetchAttempts = 3 + DefaultSelfHealingVerifierBackoffBase = 2 * time.Second + DefaultSelfHealingAuditQueryTimeout = 10 * time.Second ) diff --git a/supernode/config/lep6.go b/supernode/config/lep6.go new file mode 100644 index 00000000..a005122e --- /dev/null +++ b/supernode/config/lep6.go @@ -0,0 +1,226 @@ +package config + +import ( + "fmt" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +func (c *Config) UnmarshalYAML(value *yaml.Node) error { + type raw struct { + SupernodeConfig SupernodeConfig `yaml:"supernode"` + KeyringConfig KeyringConfig `yaml:"keyring"` + P2PConfig P2PConfig `yaml:"p2p"` + LumeraClientConfig LumeraClientConfig `yaml:"lumera"` + RaptorQConfig RaptorQConfig `yaml:"raptorq"` + StorageChallengeConfig StorageChallengeConfig `yaml:"storage_challenge"` + SelfHealingConfig SelfHealingConfig `yaml:"self_healing"` + } + var out raw + if err := value.Decode(&out); err != nil { + return err + } + c.SupernodeConfig = out.SupernodeConfig + c.KeyringConfig = out.KeyringConfig + c.P2PConfig = out.P2PConfig + c.LumeraClientConfig = out.LumeraClientConfig + c.RaptorQConfig = out.RaptorQConfig + c.StorageChallengeConfig = out.StorageChallengeConfig + c.SelfHealingConfig = out.SelfHealingConfig + return nil +} + +func (c *StorageChallengeLEP6Config) UnmarshalYAML(value *yaml.Node) error { + type raw StorageChallengeLEP6Config + var out raw + if err := value.Decode(&out); err != nil { + return err + } + *c = StorageChallengeLEP6Config(out) + c.enabledSet = hasYAMLKey(value, "enabled") + return nil +} + +func (c *StorageRecheckConfig) UnmarshalYAML(value *yaml.Node) error { + type raw StorageRecheckConfig + var out raw + if err := value.Decode(&out); err != nil { + return err + } + *c = StorageRecheckConfig(out) + c.enabledSet = hasYAMLKey(value, "enabled") + return nil +} + +func (c *SelfHealingConfig) UnmarshalYAML(value *yaml.Node) error { + type raw SelfHealingConfig + var out raw + if err := value.Decode(&out); err != nil { + return err + } + *c = SelfHealingConfig(out) + c.enabledSet = hasYAMLKey(value, "enabled") + return nil +} + +func hasYAMLKey(value *yaml.Node, key string) bool { + if value == nil || value.Kind != yaml.MappingNode { + return false + } + for i := 0; i+1 < len(value.Content); i += 2 { + if value.Content[i].Value == key { + return true + } + } + return false +} + +// applyLEP6DefaultsAndValidate applies safe defaults to LEP-6 toggles and +// runtime knobs, then runs validation. +// +// LEP-6 review C1 (Matee, 2026-05-06): the missing-block default for the +// three LEP-6 toggles (storage_challenge.lep6.enabled, +// storage_challenge.lep6.recheck.enabled, self_healing.enabled) is FALSE. +// Pre-Wave-4 the missing-block default was TRUE, which silently auto-opted +// every operator into LEP-6 on upgrade. Now an operator must explicitly +// opt in via either an explicit `enabled: true` in their YAML or by relying +// on `CreateDefaultConfig`, which writes the explicit toggles into the +// generated supernode.yml. Operators who want their existing config to +// pick up LEP-6 must add the toggles explicitly. +// +// Chain enforcement remains the protocol source of truth: even when these +// toggles are TRUE, every LEP-6 service no-ops while +// StorageTruthEnforcementMode is UNSPECIFIED (see e.g. +// LEP6Dispatcher.DispatchEpoch and self_healing.Service.Run). The toggles +// are only an operator-side opt-in switch. +func (c *Config) applyLEP6DefaultsAndValidate() error { + // LEP-6 toggles: missing-block defaults to FALSE (C1). + // enabledSet=true means the YAML had an explicit `enabled:` key — keep + // the operator's value verbatim. + if !c.StorageChallengeConfig.LEP6.enabledSet { + c.StorageChallengeConfig.LEP6.Enabled = false + } + if c.StorageChallengeConfig.LEP6.MaxConcurrentTargets == 0 { + c.StorageChallengeConfig.LEP6.MaxConcurrentTargets = DefaultLEP6MaxConcurrentTargets + } + if c.StorageChallengeConfig.LEP6.RecipientReadTimeout == 0 { + c.StorageChallengeConfig.LEP6.RecipientReadTimeout = DefaultLEP6RecipientReadTimeout + } + + recheck := &c.StorageChallengeConfig.LEP6.Recheck + if !recheck.enabledSet { + recheck.Enabled = false + } + if recheck.LookbackEpochs == 0 { + recheck.LookbackEpochs = DefaultLEP6RecheckLookbackEpochs + } + if recheck.MaxPerTick == 0 { + recheck.MaxPerTick = DefaultLEP6RecheckMaxPerTick + } + if recheck.TickIntervalMs == 0 { + recheck.TickIntervalMs = int(DefaultLEP6RecheckTickInterval / time.Millisecond) + } + if recheck.MaxFailureAttemptsPerTicket == 0 { + recheck.MaxFailureAttemptsPerTicket = DefaultLEP6RecheckMaxFailureAttemptsPerTicket + } + if recheck.FailureBackoffTTLms == 0 { + recheck.FailureBackoffTTLms = int(DefaultLEP6RecheckFailureBackoffTTL / time.Millisecond) + } + + if !c.SelfHealingConfig.enabledSet { + c.SelfHealingConfig.Enabled = false + } + if c.SelfHealingConfig.PollIntervalMs == 0 { + c.SelfHealingConfig.PollIntervalMs = int(DefaultSelfHealingPollInterval / time.Millisecond) + } + if c.SelfHealingConfig.MaxConcurrentReconstructs == 0 { + c.SelfHealingConfig.MaxConcurrentReconstructs = DefaultSelfHealingMaxConcurrentReconstructs + } + if c.SelfHealingConfig.MaxConcurrentVerifications == 0 { + c.SelfHealingConfig.MaxConcurrentVerifications = DefaultSelfHealingMaxConcurrentVerifications + } + if c.SelfHealingConfig.MaxConcurrentPublishes == 0 { + c.SelfHealingConfig.MaxConcurrentPublishes = DefaultSelfHealingMaxConcurrentPublishes + } + if strings.TrimSpace(c.SelfHealingConfig.StagingDir) == "" { + c.SelfHealingConfig.StagingDir = DefaultSelfHealingStagingDir + } + if c.SelfHealingConfig.VerifierFetchTimeoutMs == 0 { + c.SelfHealingConfig.VerifierFetchTimeoutMs = int(DefaultSelfHealingVerifierFetchTimeout / time.Millisecond) + } + if c.SelfHealingConfig.VerifierFetchAttempts == 0 { + c.SelfHealingConfig.VerifierFetchAttempts = DefaultSelfHealingVerifierFetchAttempts + } + if c.SelfHealingConfig.VerifierBackoffBaseMs == 0 { + c.SelfHealingConfig.VerifierBackoffBaseMs = int(DefaultSelfHealingVerifierBackoffBase / time.Millisecond) + } + if c.SelfHealingConfig.AuditQueryTimeoutMs == 0 { + c.SelfHealingConfig.AuditQueryTimeoutMs = int(DefaultSelfHealingAuditQueryTimeout / time.Millisecond) + } + + return c.validateLEP6Config() +} + +func (c *Config) validateLEP6Config() error { + lep6 := c.StorageChallengeConfig.LEP6 + if lep6.MaxConcurrentTargets < 0 { + return fmt.Errorf("LEP-6 config: storage_challenge.lep6.max_concurrent_targets must be >= 0") + } + if lep6.RecipientReadTimeout < 0 { + return fmt.Errorf("LEP-6 config: storage_challenge.lep6.recipient_read_timeout must be >= 0") + } + if lep6.Recheck.MaxPerTick < 0 { + return fmt.Errorf("LEP-6 config: storage_challenge.lep6.recheck.max_per_tick must be >= 0") + } + if lep6.Recheck.TickIntervalMs < 0 { + return fmt.Errorf("LEP-6 config: storage_challenge.lep6.recheck.tick_interval_ms must be >= 0") + } + if lep6.Recheck.MaxFailureAttemptsPerTicket < 0 { + return fmt.Errorf("LEP-6 config: storage_challenge.lep6.recheck.max_failure_attempts_per_ticket must be >= 0") + } + if lep6.Recheck.FailureBackoffTTLms < 0 { + return fmt.Errorf("LEP-6 config: storage_challenge.lep6.recheck.failure_backoff_ttl_ms must be >= 0") + } + sh := c.SelfHealingConfig + if sh.PollIntervalMs < 0 { + return fmt.Errorf("LEP-6 config: self_healing.poll_interval_ms must be >= 0") + } + if sh.MaxConcurrentReconstructs < 0 { + return fmt.Errorf("LEP-6 config: self_healing.max_concurrent_reconstructs must be >= 0") + } + if sh.MaxConcurrentVerifications < 0 { + return fmt.Errorf("LEP-6 config: self_healing.max_concurrent_verifications must be >= 0") + } + if sh.MaxConcurrentPublishes < 0 { + return fmt.Errorf("LEP-6 config: self_healing.max_concurrent_publishes must be >= 0") + } + if sh.VerifierFetchTimeoutMs < 0 { + return fmt.Errorf("LEP-6 config: self_healing.verifier_fetch_timeout_ms must be >= 0") + } + if sh.VerifierFetchAttempts < 0 { + return fmt.Errorf("LEP-6 config: self_healing.verifier_fetch_attempts must be >= 0") + } + if sh.VerifierBackoffBaseMs < 0 { + return fmt.Errorf("LEP-6 config: self_healing.verifier_backoff_base_ms must be >= 0") + } + if sh.AuditQueryTimeoutMs < 0 { + return fmt.Errorf("LEP-6 config: self_healing.audit_query_timeout_ms must be >= 0") + } + + // LEP-6 review L6 (Matee, 2026-05-06): structural consistency check — + // the recheck runtime is only spawned when storage_challenge.enabled AND + // storage_challenge.lep6.enabled are both true (see supernode/cmd/start.go). + // Catching this at config-load surfaces the dead block at startup + // rather than letting it silently no-op. + if lep6.Recheck.Enabled { + if !c.StorageChallengeConfig.Enabled { + return fmt.Errorf("LEP-6 config: storage_challenge.lep6.recheck.enabled=true requires storage_challenge.enabled=true (recheck runtime is gated by parent storage_challenge service)") + } + if !lep6.Enabled { + return fmt.Errorf("LEP-6 config: storage_challenge.lep6.recheck.enabled=true requires storage_challenge.lep6.enabled=true (recheck runtime is gated by parent LEP-6 dispatcher)") + } + } + return nil +} diff --git a/supernode/config/lep6_advisory.go b/supernode/config/lep6_advisory.go new file mode 100644 index 00000000..350100ea --- /dev/null +++ b/supernode/config/lep6_advisory.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" + "strings" +) + +// LEP6OperatorOptInAdvisory returns a non-empty advisory string when this +// supernode is opted out of any LEP-6 service. Empty string means everything +// is opted in. +// +// LEP-6 review C1 (Matee, 2026-05-06): pre-Wave-4 the missing-block default +// for LEP-6 toggles was TRUE, which silently auto-opted operators in. After +// Wave 4 the default is FALSE — but that opens the inverse risk: an operator +// who upgrades and forgets to add the toggles now silently opts OUT while +// the chain may be enforcing SOFT/FULL. This helper produces a startup +// advisory that supernode/cmd/start.go logs at WARN so operators can tell +// at a glance which LEP-6 services are off. The chain enforcement mode +// remains authoritative — even when toggles are TRUE every LEP-6 service +// no-ops while the chain is UNSPECIFIED, so the safe failure mode is +// "operator opts in unconditionally; chain decides when work happens". +// +// The advisory is purely informational; it does not change any behaviour. +func (c *Config) LEP6OperatorOptInAdvisory() string { + disabled := make([]string, 0, 3) + if !c.StorageChallengeConfig.LEP6.Enabled { + disabled = append(disabled, "storage_challenge.lep6.enabled=false") + } + if !c.StorageChallengeConfig.LEP6.Recheck.Enabled { + disabled = append(disabled, "storage_challenge.lep6.recheck.enabled=false") + } + if !c.SelfHealingConfig.Enabled { + disabled = append(disabled, "self_healing.enabled=false") + } + if len(disabled) == 0 { + return "" + } + return fmt.Sprintf( + "LEP-6: operator opted out of one or more services — [%s]; "+ + "this supernode will not produce reports / heal-ops / recheck evidence for the disabled services. "+ + "If chain enforcement is SOFT or FULL this can incur scoring penalties; flip the toggles in supernode.yml to opt in", + strings.Join(disabled, ", "), + ) +} diff --git a/supernode/config/save.go b/supernode/config/save.go index dfa88b7f..80b58ab8 100644 --- a/supernode/config/save.go +++ b/supernode/config/save.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "time" "gopkg.in/yaml.v3" ) @@ -61,6 +62,30 @@ func CreateDefaultConfig(keyName, identity, chainID string, keyringBackend, keyr Enabled: true, PollIntervalMs: DefaultStorageChallengePollIntervalMs, SubmitEvidence: true, + LEP6: StorageChallengeLEP6Config{ + Enabled: true, + MaxConcurrentTargets: DefaultLEP6MaxConcurrentTargets, + RecipientReadTimeout: DefaultLEP6RecipientReadTimeout, + Recheck: StorageRecheckConfig{ + Enabled: true, + LookbackEpochs: DefaultLEP6RecheckLookbackEpochs, + MaxPerTick: DefaultLEP6RecheckMaxPerTick, + TickIntervalMs: int(DefaultLEP6RecheckTickInterval / time.Millisecond), + MaxFailureAttemptsPerTicket: DefaultLEP6RecheckMaxFailureAttemptsPerTicket, + FailureBackoffTTLms: int(DefaultLEP6RecheckFailureBackoffTTL / time.Millisecond), + }, + }, + }, + SelfHealingConfig: SelfHealingConfig{ + Enabled: true, + PollIntervalMs: int(DefaultSelfHealingPollInterval / time.Millisecond), + MaxConcurrentReconstructs: DefaultSelfHealingMaxConcurrentReconstructs, + MaxConcurrentVerifications: DefaultSelfHealingMaxConcurrentVerifications, + MaxConcurrentPublishes: DefaultSelfHealingMaxConcurrentPublishes, + StagingDir: DefaultSelfHealingStagingDir, + VerifierFetchTimeoutMs: int(DefaultSelfHealingVerifierFetchTimeout / time.Millisecond), + VerifierFetchAttempts: DefaultSelfHealingVerifierFetchAttempts, + VerifierBackoffBaseMs: int(DefaultSelfHealingVerifierBackoffBase / time.Millisecond), }, } } diff --git a/supernode/config/wave4_regression_test.go b/supernode/config/wave4_regression_test.go new file mode 100644 index 00000000..8fe5255b --- /dev/null +++ b/supernode/config/wave4_regression_test.go @@ -0,0 +1,153 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Wave 4 — LEP-6 PR286 review fix regression tests. +// +// Coverage: +// - C1: missing-block default for LEP-6 toggles is FALSE (no silent +// upgrade-time opt-in). Already covered structurally by +// TestLoadConfig_LEP6SafeDefaults; this file adds focused negative +// cases (wrong-direction default would cause auto-opt-in) and the +// advisory helper. +// - L6: structural validator rejects recheck=true with disabled parents. +// Pre-Wave-4, fixtures could carry recheck.enabled=true while +// storage_challenge.enabled=false, silently no-op'd at runtime. + +func TestLoadConfig_C1_MissingBlocksDefaultDisabled(t *testing.T) { + t.Parallel() + + // No LEP-6 / recheck / self_healing block at all — defaults must be FALSE. + cfg := loadConfigFromBody(t, baseConfigYAML()) + + if cfg.StorageChallengeConfig.LEP6.Enabled { + t.Fatalf("C1: storage_challenge.lep6.enabled = true on missing-block; want false (no silent opt-in)") + } + if cfg.StorageChallengeConfig.LEP6.Recheck.Enabled { + t.Fatalf("C1: storage_challenge.lep6.recheck.enabled = true on missing-block; want false") + } + if cfg.SelfHealingConfig.Enabled { + t.Fatalf("C1: self_healing.enabled = true on missing-block; want false") + } +} + +func TestLoadConfig_C1_ExplicitTrueRespected(t *testing.T) { + t.Parallel() + + cfg := loadConfigFromBody(t, baseConfigYAML()+` +storage_challenge: + enabled: true + lep6: + enabled: true + recheck: + enabled: true +self_healing: + enabled: true +`) + + if !cfg.StorageChallengeConfig.LEP6.Enabled { + t.Fatalf("C1: explicit storage_challenge.lep6.enabled=true must be respected") + } + if !cfg.StorageChallengeConfig.LEP6.Recheck.Enabled { + t.Fatalf("C1: explicit recheck.enabled=true must be respected") + } + if !cfg.SelfHealingConfig.Enabled { + t.Fatalf("C1: explicit self_healing.enabled=true must be respected") + } +} + +func TestLoadConfig_C1_OptInAdvisory(t *testing.T) { + t.Parallel() + + // All three opted out — advisory must mention each disabled service. + allOff := loadConfigFromBody(t, baseConfigYAML()) + advisory := allOff.LEP6OperatorOptInAdvisory() + if advisory == "" { + t.Fatalf("C1: advisory must be non-empty when toggles are off") + } + for _, want := range []string{ + "storage_challenge.lep6.enabled=false", + "storage_challenge.lep6.recheck.enabled=false", + "self_healing.enabled=false", + } { + if !strings.Contains(advisory, want) { + t.Fatalf("C1 advisory missing %q in:\n%s", want, advisory) + } + } + + // All three opted in — advisory must be empty. + allOn := loadConfigFromBody(t, baseConfigYAML()+` +storage_challenge: + enabled: true + lep6: + enabled: true + recheck: + enabled: true +self_healing: + enabled: true +`) + if got := allOn.LEP6OperatorOptInAdvisory(); got != "" { + t.Fatalf("C1 advisory should be empty when all opted in; got %q", got) + } +} + +func TestLoadConfig_L6_RecheckRequiresParents(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + body string + wantErrMatch string + }{ + "recheck_true_storage_disabled": { + body: baseConfigYAML() + ` +storage_challenge: + enabled: false + lep6: + enabled: true + recheck: + enabled: true +`, + wantErrMatch: "storage_challenge.enabled=true", + }, + "recheck_true_lep6_disabled": { + body: baseConfigYAML() + ` +storage_challenge: + enabled: true + lep6: + enabled: false + recheck: + enabled: true +`, + wantErrMatch: "storage_challenge.lep6.enabled=true", + }, + } + + for name, tc := range cases { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "supernode.yml") + if err := writeFile(t, path, tc.body); err != nil { + t.Fatalf("write: %v", err) + } + _, err := LoadConfig(path, dir) + if err == nil { + t.Fatalf("L6: LoadConfig succeeded; want validator rejection for %s", name) + } + if !strings.Contains(err.Error(), tc.wantErrMatch) { + t.Fatalf("L6: error %q does not contain %q", err.Error(), tc.wantErrMatch) + } + }) + } +} + +func writeFile(t *testing.T, path, body string) error { + t.Helper() + return os.WriteFile(path, []byte(body), 0o600) +} diff --git a/supernode/config/wave4_staging_test.go b/supernode/config/wave4_staging_test.go new file mode 100644 index 00000000..d94cebf4 --- /dev/null +++ b/supernode/config/wave4_staging_test.go @@ -0,0 +1,33 @@ +package config + +import ( + "path/filepath" + "strings" + "testing" +) + +// TestM1_StagingDirResolvesAgainstBaseDir pins LEP-6 review M1: the default +// `heal-staging` is relative; supernode/cmd/start.go must resolve it against +// appConfig.BaseDir via GetFullPath BEFORE handing it to the self-healing +// service. We validate the resolution helper directly. +func TestM1_StagingDirResolvesAgainstBaseDir(t *testing.T) { + t.Parallel() + + baseDir := "/var/lib/supernode" + c := &Config{BaseDir: baseDir, SelfHealingConfig: SelfHealingConfig{StagingDir: DefaultSelfHealingStagingDir}} + + got := c.GetFullPath(c.SelfHealingConfig.StagingDir) + want := filepath.Join(baseDir, DefaultSelfHealingStagingDir) + if got != want { + t.Fatalf("M1: GetFullPath relative resolution = %q, want %q", got, want) + } + if !strings.HasPrefix(got, baseDir+string(filepath.Separator)) { + t.Fatalf("M1: resolved path %q is not under base dir %q", got, baseDir) + } + + // Absolute path stays absolute (no double-prepend). + c.SelfHealingConfig.StagingDir = "/srv/heal-staging" + if got := c.GetFullPath(c.SelfHealingConfig.StagingDir); got != "/srv/heal-staging" { + t.Fatalf("M1: absolute path mangled = %q, want /srv/heal-staging", got) + } +} diff --git a/supernode/host_reporter/service.go b/supernode/host_reporter/service.go index e0c1c16e..73c96353 100644 --- a/supernode/host_reporter/service.go +++ b/supernode/host_reporter/service.go @@ -2,12 +2,15 @@ package host_reporter import ( "context" + "errors" "fmt" "net" "os" "path/filepath" "strconv" "strings" + "sync" + "syscall" "time" audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" @@ -28,6 +31,25 @@ const ( maxConcurrentTargets = 8 ) +// ProofResultProvider supplies the LEP-6 storage proof results that the host +// reporter must include in MsgSubmitEpochReport for a given epoch. The storage +// challenge runtime (PR3) implements this; until then the field stays nil and +// the host reporter submits an empty storage_proof_results slice. +type ProofResultProvider interface { + // CollectResults returns the storage proof results buffered for epochID and + // clears the buffer. Implementations must be safe for concurrent use. + // Returning nil or an empty slice is valid (no proofs produced this epoch). + CollectResults(epochID uint64) []*audittypes.StorageProofResult +} + +// ProofResultRequeuer is implemented by providers that can put drained results +// back if the host reporter decides not to submit the epoch report. This keeps +// the FULL-mode coverage guard from losing late-arriving results when it aborts +// a would-be chain-rejected partial report. +type ProofResultRequeuer interface { + RequeueResults(epochID uint64, results []*audittypes.StorageProofResult) +} + // Service submits one MsgSubmitEpochReport per epoch for the local supernode. // All runtime behavior is driven by on-chain params/queries; there are no local config knobs. type Service struct { @@ -43,6 +65,24 @@ type Service struct { metrics *statussvc.MetricsCollector storagePaths []string p2pDataDir string + + proofResultProviderMu sync.RWMutex + proofResultProvider ProofResultProvider +} + +// SetProofResultProvider attaches a ProofResultProvider to be drained on each +// epoch report. Wiring happens in supernode/cmd/start.go after the storage +// challenge runtime is constructed. It is safe to call before or after Run. +func (s *Service) SetProofResultProvider(p ProofResultProvider) { + s.proofResultProviderMu.Lock() + defer s.proofResultProviderMu.Unlock() + s.proofResultProvider = p +} + +func (s *Service) getProofResultProvider() ProofResultProvider { + s.proofResultProviderMu.RLock() + defer s.proofResultProviderMu.RUnlock() + return s.proofResultProvider } func NewService(identity string, lumeraClient lumera.Client, kr keyring.Keyring, keyName string, baseDir string, p2pDataDir string) (*Service, error) { @@ -143,6 +183,26 @@ func (s *Service) tick(ctx context.Context) { storageChallengeObservations := s.buildStorageChallengeObservations(tickCtx, epochID, assignResp.RequiredOpenPorts, assignResp.TargetSupernodeAccounts) + var storageProofResults []*audittypes.StorageProofResult + if proofResultProvider := s.getProofResultProvider(); proofResultProvider != nil { + storageProofResults = proofResultProvider.CollectResults(epochID) + if s.fullModeStorageProofCoverageRequired(tickCtx) { + complete, reason := storageProofCoverageComplete(storageProofResults, assignResp.TargetSupernodeAccounts) + if !complete { + if requeuer, ok := proofResultProvider.(ProofResultRequeuer); ok { + requeuer.RequeueResults(epochID, storageProofResults) + } + logtrace.Warn(tickCtx, "epoch report skipped: incomplete FULL-mode storage proof coverage", logtrace.Fields{ + "epoch_id": epochID, + "assigned_targets": len(assignResp.TargetSupernodeAccounts), + "proof_results": len(storageProofResults), + "reason": reason, + }) + return + } + } + } + hostReport := audittypes.HostReport{ // Intentionally submit 0% usage for CPU/memory so the chain treats these as "unknown". // Disk usage is reported accurately (legacy-aligned) so disk-based enforcement can work. @@ -152,11 +212,11 @@ func (s *Service) tick(ctx context.Context) { if diskUsagePercent, ok := s.diskUsagePercent(tickCtx); ok { hostReport.DiskUsagePercent = diskUsagePercent } - if cascadeBytes, ok := s.cascadeKademliaDBBytes(tickCtx); ok { - hostReport.CascadeKademliaDbBytes = float64(cascadeBytes) - } + // Final Lumera LEP-6 HostReport no longer carries Cascade Kademlia DB byte counters; + // keep disk usage as the host-side enforcement metric and leave the local helper intact + // for existing diagnostics/tests. - if _, err := s.lumera.AuditMsg().SubmitEpochReport(tickCtx, epochID, hostReport, storageChallengeObservations); err != nil { + if _, err := s.lumera.AuditMsg().SubmitEpochReport(tickCtx, epochID, hostReport, storageChallengeObservations, storageProofResults); err != nil { logtrace.Warn(tickCtx, "epoch report submit failed", logtrace.Fields{ "epoch_id": epochID, "error": err.Error(), @@ -167,9 +227,54 @@ func (s *Service) tick(ctx context.Context) { logtrace.Info(tickCtx, "epoch report submitted", logtrace.Fields{ "epoch_id": epochID, "storage_challenge_observations_count": len(storageChallengeObservations), + "storage_proof_results_count": len(storageProofResults), }) } +func (s *Service) fullModeStorageProofCoverageRequired(ctx context.Context) bool { + paramsResp, err := s.lumera.Audit().GetParams(ctx) + if err != nil || paramsResp == nil { + return false + } + return paramsResp.Params.StorageTruthEnforcementMode == audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL +} + +func storageProofCoverageComplete(results []*audittypes.StorageProofResult, targets []string) (bool, string) { + if len(targets) == 0 { + return true, "" + } + type coverage struct{ recent, old int } + byTarget := make(map[string]*coverage, len(targets)) + for _, target := range targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + byTarget[target] = &coverage{} + } + for _, result := range results { + if result == nil { + continue + } + cov := byTarget[result.TargetSupernodeAccount] + if cov == nil { + continue + } + switch result.BucketType { + case audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT: + cov.recent++ + case audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD: + cov.old++ + } + } + for target, cov := range byTarget { + if cov.recent != 1 || cov.old != 1 { + return false, fmt.Sprintf("target %s has recent=%d old=%d; FULL requires exactly one each", target, cov.recent, cov.old) + } + } + return true, "" +} + func (s *Service) diskUsagePercent(ctx context.Context) (float64, bool) { if s.metrics == nil || len(s.storagePaths) == 0 { return 0, false @@ -309,6 +414,22 @@ func normalizeProbeHost(raw string) string { return raw } +// probeTCP attempts a short TCP dial to (host, port) and maps the outcome to +// an audittypes.PortState. +// +// LEP-6 review M6 (Matee, 2026-05-06): pre-Wave-4 every dial error mapped to +// PORT_STATE_CLOSED, including transient operator-side faults (DNS +// resolution failure, EHOSTUNREACH, context cancellation). Reporting these +// as CLOSED told the chain "this peer's port is down" when in fact our +// reporter just couldn't resolve / route to it. Post-Wave-4: +// - ECONNREFUSED → CLOSED (the canonical "port is closed" signal — TCP +// stack got a RST from the peer's host, which means the host is up +// but no process is listening). +// - DNS error / EHOSTUNREACH / ENETUNREACH / ctx.Err() / dial timeout → +// UNKNOWN with a structured WARN log so operators can see the noise. +// - Anything else also maps to UNKNOWN (default-safe). UNKNOWN does not +// contribute to scoring, so this errs on the side of "don't accuse the +// peer when we are not sure". func probeTCP(ctx context.Context, host string, port uint32, timeout time.Duration) audittypes.PortState { host = strings.TrimSpace(host) if host == "" { @@ -320,9 +441,39 @@ func probeTCP(ctx context.Context, host string, port uint32, timeout time.Durati d := net.Dialer{Timeout: timeout} conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10))) - if err != nil { + if err == nil { + _ = conn.Close() + return audittypes.PortState_PORT_STATE_OPEN + } + + // Canonical CLOSED signal: RST from peer's host (host up, no listener). + if errors.Is(err, syscall.ECONNREFUSED) { return audittypes.PortState_PORT_STATE_CLOSED } - _ = conn.Close() - return audittypes.PortState_PORT_STATE_OPEN + + // Operator-side / network-fault classes — UNKNOWN with structured warn. + switch { + case errors.Is(err, syscall.EHOSTUNREACH), + errors.Is(err, syscall.ENETUNREACH), + errors.Is(err, context.Canceled), + errors.Is(err, context.DeadlineExceeded): + // fall through to UNKNOWN below + default: + // DNS errors are *net.DNSError; timeouts implement net.Error.Timeout(). + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + // fall through + } else { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + // fall through + } + } + } + logtrace.Warn(ctx, "host_reporter: probeTCP unclassified or operator-side fault — reporting UNKNOWN", logtrace.Fields{ + "host": host, + "port": port, + "error": err.Error(), + }) + return audittypes.PortState_PORT_STATE_UNKNOWN } diff --git a/supernode/host_reporter/tick_behavior_test.go b/supernode/host_reporter/tick_behavior_test.go index 27927c72..b2e02937 100644 --- a/supernode/host_reporter/tick_behavior_test.go +++ b/supernode/host_reporter/tick_behavior_test.go @@ -16,6 +16,7 @@ import ( cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" "github.com/cosmos/cosmos-sdk/crypto/hd" "github.com/cosmos/cosmos-sdk/crypto/keyring" + "github.com/cosmos/cosmos-sdk/types/query" sdktx "github.com/cosmos/cosmos-sdk/types/tx" "github.com/cosmos/go-bip39" "go.uber.org/mock/gomock" @@ -26,12 +27,14 @@ import ( type stubAuditModule struct { currentEpoch *audittypes.QueryCurrentEpochResponse anchor *audittypes.QueryEpochAnchorResponse + epochReport *audittypes.QueryEpochReportResponse epochReportErr error assigned *audittypes.QueryAssignedTargetsResponse + params audittypes.Params } func (s *stubAuditModule) GetParams(ctx context.Context) (*audittypes.QueryParamsResponse, error) { - return &audittypes.QueryParamsResponse{}, nil + return &audittypes.QueryParamsResponse{Params: s.params}, nil } func (s *stubAuditModule) GetEpochAnchor(ctx context.Context, epochID uint64) (*audittypes.QueryEpochAnchorResponse, error) { return s.anchor, nil @@ -49,7 +52,28 @@ func (s *stubAuditModule) GetEpochReport(ctx context.Context, epochID uint64, su if s.epochReportErr != nil { return nil, s.epochReportErr } - return &audittypes.QueryEpochReportResponse{}, nil + return s.epochReport, nil +} +func (s *stubAuditModule) GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*audittypes.QueryEpochReportsByReporterResponse, error) { + return &audittypes.QueryEpochReportsByReporterResponse{}, nil +} +func (s *stubAuditModule) GetNodeSuspicionState(ctx context.Context, supernodeAccount string) (*audittypes.QueryNodeSuspicionStateResponse, error) { + return &audittypes.QueryNodeSuspicionStateResponse{}, nil +} +func (s *stubAuditModule) GetReporterReliabilityState(ctx context.Context, reporterAccount string) (*audittypes.QueryReporterReliabilityStateResponse, error) { + return &audittypes.QueryReporterReliabilityStateResponse{}, nil +} +func (s *stubAuditModule) GetTicketDeteriorationState(ctx context.Context, ticketID string) (*audittypes.QueryTicketDeteriorationStateResponse, error) { + return &audittypes.QueryTicketDeteriorationStateResponse{}, nil +} +func (s *stubAuditModule) GetHealOp(ctx context.Context, healOpID uint64) (*audittypes.QueryHealOpResponse, error) { + return &audittypes.QueryHealOpResponse{}, nil +} +func (s *stubAuditModule) GetHealOpsByStatus(ctx context.Context, status audittypes.HealOpStatus, pagination *query.PageRequest) (*audittypes.QueryHealOpsByStatusResponse, error) { + return &audittypes.QueryHealOpsByStatusResponse{}, nil +} +func (s *stubAuditModule) GetHealOpsByTicket(ctx context.Context, ticketID string, pagination *query.PageRequest) (*audittypes.QueryHealOpsByTicketResponse, error) { + return &audittypes.QueryHealOpsByTicketResponse{}, nil } func testKeyringAndIdentity(t *testing.T) (keyring.Keyring, string, string) { @@ -109,8 +133,8 @@ func TestTick_ProberSubmitsObservationsForAssignedTargets(t *testing.T) { sn.EXPECT().GetSupernodeWithLatestAddress(gomock.Any(), "snA").Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1:4444"}, nil) sn.EXPECT().GetSupernodeWithLatestAddress(gomock.Any(), "snB").Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1:4444"}, nil) - auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), uint64(7), gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, _ uint64, _ audittypes.HostReport, obs []*audittypes.StorageChallengeObservation) (*sdktx.BroadcastTxResponse, error) { + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), uint64(7), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ uint64, _ audittypes.HostReport, obs []*audittypes.StorageChallengeObservation, proofs []*audittypes.StorageProofResult) (*sdktx.BroadcastTxResponse, error) { if len(obs) != 2 { t.Fatalf("expected 2 observations, got %d", len(obs)) } @@ -119,6 +143,9 @@ func TestTick_ProberSubmitsObservationsForAssignedTargets(t *testing.T) { t.Fatalf("invalid observation: %+v", o) } } + if len(proofs) != 0 { + t.Fatalf("expected 0 proof results when no provider attached, got %d", len(proofs)) + } return &sdktx.BroadcastTxResponse{}, nil }, ) @@ -153,8 +180,8 @@ func TestTick_NonProberSubmitsHostOnly(t *testing.T) { client.EXPECT().AuditMsg().AnyTimes().Return(auditMsg) client.EXPECT().SuperNode().AnyTimes().Return(sn) client.EXPECT().Node().AnyTimes().Return(node) - auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), uint64(8), gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, _ uint64, _ audittypes.HostReport, obs []*audittypes.StorageChallengeObservation) (*sdktx.BroadcastTxResponse, error) { + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), uint64(8), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ uint64, _ audittypes.HostReport, obs []*audittypes.StorageChallengeObservation, _ []*audittypes.StorageProofResult) (*sdktx.BroadcastTxResponse, error) { if len(obs) != 0 { t.Fatalf("expected 0 observations for non-prober, got %d", len(obs)) } @@ -188,7 +215,7 @@ func TestTick_SkipsWhenEpochAlreadyReported(t *testing.T) { client.EXPECT().AuditMsg().AnyTimes().Return(auditMsg) client.EXPECT().SuperNode().AnyTimes().Return(sn) client.EXPECT().Node().AnyTimes().Return(node) - auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) svc, err := NewService(identity, client, kr, keyName, "", "") if err != nil { @@ -216,7 +243,7 @@ func TestTick_SkipsOnEpochReportLookupError(t *testing.T) { client.EXPECT().AuditMsg().AnyTimes().Return(auditMsg) client.EXPECT().SuperNode().AnyTimes().Return(sn) client.EXPECT().Node().AnyTimes().Return(node) - auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) svc, err := NewService(identity, client, kr, keyName, "", "") if err != nil { @@ -224,3 +251,117 @@ func TestTick_SkipsOnEpochReportLookupError(t *testing.T) { } svc.tick(context.Background()) } + +// stubProofResultProvider records the epoch it was queried with and returns a +// fixed slice of synthetic StorageProofResult records. +type stubProofResultProvider struct { + queriedEpochs []uint64 + requeuedEpochs []uint64 + results []*audittypes.StorageProofResult +} + +func (s *stubProofResultProvider) CollectResults(epochID uint64) []*audittypes.StorageProofResult { + s.queriedEpochs = append(s.queriedEpochs, epochID) + return s.results +} + +func (s *stubProofResultProvider) RequeueResults(epochID uint64, results []*audittypes.StorageProofResult) { + s.requeuedEpochs = append(s.requeuedEpochs, epochID) + s.results = append([]*audittypes.StorageProofResult(nil), results...) +} + +func TestTick_AttachedProofResultProviderIsDrainedAndForwarded(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + kr, keyName, identity := testKeyringAndIdentity(t) + auditMod := &stubAuditModule{ + currentEpoch: &audittypes.QueryCurrentEpochResponse{EpochId: 11}, + anchor: &audittypes.QueryEpochAnchorResponse{Anchor: audittypes.EpochAnchor{EpochId: 11}}, + epochReportErr: status.Error(codes.NotFound, "not found"), + assigned: &audittypes.QueryAssignedTargetsResponse{}, + } + auditMsg := auditmsgmod.NewMockModule(ctrl) + node := nodemod.NewMockModule(ctrl) + sn := supernodemod.NewMockModule(ctrl) + client := lumeraMock.NewMockClient(ctrl) + client.EXPECT().Audit().AnyTimes().Return(auditMod) + client.EXPECT().AuditMsg().AnyTimes().Return(auditMsg) + client.EXPECT().SuperNode().AnyTimes().Return(sn) + client.EXPECT().Node().AnyTimes().Return(node) + + provider := &stubProofResultProvider{ + results: []*audittypes.StorageProofResult{ + {TargetSupernodeAccount: "snA", TicketId: "ticket-1", TranscriptHash: "hash-1"}, + {TargetSupernodeAccount: "snB", TicketId: "ticket-2", TranscriptHash: "hash-2"}, + }, + } + + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), uint64(11), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ uint64, _ audittypes.HostReport, _ []*audittypes.StorageChallengeObservation, proofs []*audittypes.StorageProofResult) (*sdktx.BroadcastTxResponse, error) { + if len(proofs) != 2 { + t.Fatalf("expected 2 proof results from provider, got %d", len(proofs)) + } + if proofs[0].TicketId != "ticket-1" || proofs[1].TicketId != "ticket-2" { + t.Fatalf("proof results not forwarded verbatim: %+v", proofs) + } + return &sdktx.BroadcastTxResponse{}, nil + }, + ) + + svc, err := NewService(identity, client, kr, keyName, "", "") + if err != nil { + t.Fatalf("new service: %v", err) + } + svc.SetProofResultProvider(provider) + svc.tick(context.Background()) + + if len(provider.queriedEpochs) != 1 || provider.queriedEpochs[0] != 11 { + t.Fatalf("expected provider queried once for epoch 11, got %v", provider.queriedEpochs) + } +} + +func TestTick_FULLModeIncompleteStorageProofCoverageSkipsSubmitAndRequeues(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + kr, keyName, identity := testKeyringAndIdentity(t) + auditMod := &stubAuditModule{ + currentEpoch: &audittypes.QueryCurrentEpochResponse{EpochId: 12}, + anchor: &audittypes.QueryEpochAnchorResponse{Anchor: audittypes.EpochAnchor{EpochId: 12}}, + epochReportErr: status.Error(codes.NotFound, "not found"), + assigned: &audittypes.QueryAssignedTargetsResponse{ + TargetSupernodeAccounts: []string{"snA"}, + RequiredOpenPorts: nil, + }, + params: audittypes.Params{StorageTruthEnforcementMode: audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL}, + } + auditMsg := auditmsgmod.NewMockModule(ctrl) + node := nodemod.NewMockModule(ctrl) + sn := supernodemod.NewMockModule(ctrl) + client := lumeraMock.NewMockClient(ctrl) + client.EXPECT().Audit().AnyTimes().Return(auditMod) + 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:4444"}, nil) + 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"}, + }} + + svc, err := NewService(identity, client, kr, keyName, "", "") + if err != nil { + t.Fatalf("new service: %v", err) + } + svc.SetProofResultProvider(provider) + svc.tick(context.Background()) + + if len(provider.queriedEpochs) != 1 || provider.queriedEpochs[0] != 12 { + t.Fatalf("expected provider queried once for epoch 12, got %v", provider.queriedEpochs) + } + if len(provider.requeuedEpochs) != 1 || provider.requeuedEpochs[0] != 12 { + t.Fatalf("expected incomplete FULL proofs requeued for epoch 12, got %v", provider.requeuedEpochs) + } +} diff --git a/supernode/host_reporter/wave4_probetcp_test.go b/supernode/host_reporter/wave4_probetcp_test.go new file mode 100644 index 00000000..ab2494e9 --- /dev/null +++ b/supernode/host_reporter/wave4_probetcp_test.go @@ -0,0 +1,103 @@ +package host_reporter + +import ( + "context" + "net" + "strconv" + "testing" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +// Wave 4 — LEP-6 review M6 (Matee, 2026-05-06). probeTCP must distinguish +// canonical CLOSED (ECONNREFUSED) from operator-side faults (DNS, host +// unreach, ctx errors, timeouts) which now report UNKNOWN. + +// TestProbeTCP_M6_OpenPortReturnsOpen exercises the happy path: a listener +// bound to 127.0.0.1: answers the dial → PORT_STATE_OPEN. +func TestProbeTCP_M6_OpenPortReturnsOpen(t *testing.T) { + t.Parallel() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + // Accept goroutine: silently close any inbound conn. + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + _ = c.Close() + } + }() + + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port, _ := strconv.Atoi(portStr) + + got := probeTCP(context.Background(), "127.0.0.1", uint32(port), 2*time.Second) + if got != audittypes.PortState_PORT_STATE_OPEN { + t.Fatalf("M6 happy path: got %s, want OPEN", got.String()) + } +} + +// TestProbeTCP_M6_RefusedReturnsClosed pins ECONNREFUSED → CLOSED. We bind +// a port, close the listener, then dial — kernel issues RST. +func TestProbeTCP_M6_RefusedReturnsClosed(t *testing.T) { + t.Parallel() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port, _ := strconv.Atoi(portStr) + ln.Close() // close immediately so subsequent dials get RST + + got := probeTCP(context.Background(), "127.0.0.1", uint32(port), 2*time.Second) + if got != audittypes.PortState_PORT_STATE_CLOSED { + t.Fatalf("M6 refused: got %s, want CLOSED (ECONNREFUSED is the canonical closed signal)", got.String()) + } +} + +// TestProbeTCP_M6_DNSFailureReturnsUnknown — pre-Wave-4 a DNS resolution +// failure mapped to CLOSED, falsely accusing the peer's port of being shut. +// Now must map to UNKNOWN. +func TestProbeTCP_M6_DNSFailureReturnsUnknown(t *testing.T) { + t.Parallel() + + // .invalid is reserved by RFC 2606 for DNS-resolution-failure tests; + // no resolver should ever return an A record. + got := probeTCP(context.Background(), "no-such-host.invalid", 9999, 2*time.Second) + if got != audittypes.PortState_PORT_STATE_UNKNOWN { + t.Fatalf("M6 DNS fail: got %s, want UNKNOWN", got.String()) + } +} + +// TestProbeTCP_M6_CtxCanceledReturnsUnknown pins ctx cancellation → UNKNOWN. +func TestProbeTCP_M6_CtxCanceledReturnsUnknown(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before dial + got := probeTCP(ctx, "127.0.0.1", 9999, 2*time.Second) + if got != audittypes.PortState_PORT_STATE_UNKNOWN { + t.Fatalf("M6 canceled ctx: got %s, want UNKNOWN", got.String()) + } +} + +// TestProbeTCP_M6_DialTimeoutReturnsUnknown pins net.Error.Timeout → UNKNOWN. +// We dial a non-routable host (TEST-NET-1) with a tiny timeout. +func TestProbeTCP_M6_DialTimeoutReturnsUnknown(t *testing.T) { + t.Parallel() + + // 192.0.2.0/24 is RFC 5737 TEST-NET-1, guaranteed not-routable. + got := probeTCP(context.Background(), "192.0.2.1", 9999, 50*time.Millisecond) + if got != audittypes.PortState_PORT_STATE_UNKNOWN { + t.Fatalf("M6 dial timeout: got %s, want UNKNOWN", got.String()) + } +} diff --git a/supernode/recheck/attestor.go b/supernode/recheck/attestor.go new file mode 100644 index 00000000..72717ff7 --- /dev/null +++ b/supernode/recheck/attestor.go @@ -0,0 +1,101 @@ +package recheck + +import ( + "context" + "errors" + "fmt" + "strings" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/chainerrors" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" + "github.com/LumeraProtocol/supernode/v2/pkg/storage/queries" + sdktx "github.com/cosmos/cosmos-sdk/types/tx" +) + +type TxSubmitter interface { + SubmitStorageRecheckEvidence(ctx context.Context, epochID uint64, challengedSupernodeAccount, ticketID, challengedResultTranscriptHash, recheckTranscriptHash string, recheckResultClass audittypes.StorageProofResultClass, details string) (*sdktx.BroadcastTxResponse, error) +} + +type Attestor struct { + self string + msg TxSubmitter + store Store +} + +func NewAttestor(self string, msg TxSubmitter, store Store) *Attestor { + return &Attestor{self: strings.TrimSpace(self), msg: msg, store: store} +} + +func (a *Attestor) Submit(ctx context.Context, c Candidate, r RecheckResult) error { + if a == nil || a.msg == nil || a.store == nil { + return fmt.Errorf("recheck attestor missing deps") + } + if !c.Valid() || c.TargetAccount == a.self || c.OriginalReporter == a.self { + return fmt.Errorf("invalid recheck candidate") + } + if strings.TrimSpace(r.TranscriptHash) == "" || !validRecheckResultClass(r.ResultClass) { + logtrace.Warn(ctx, "lep6 recheck: dropping invalid local recheck result", logtrace.Fields{ + "epoch_id": c.EpochID, + "ticket_id": c.TicketID, + "target": c.TargetAccount, + "result_class": r.ResultClass.String(), + }) + return fmt.Errorf("invalid recheck result") + } + if err := a.store.RecordPendingRecheckSubmission(ctx, c.EpochID, c.TicketID, c.TargetAccount, c.ChallengedTranscriptHash, r.TranscriptHash, r.ResultClass); err != nil { + // L3 fix: a duplicate (epoch, ticket, target) row is now a typed + // signal — treat as already-attempted-this-tick and skip. + if errors.Is(err, queries.ErrLEP6RecheckAlreadyRecorded) { + lep6metrics.IncRecheckSubmission(r.ResultClass.String(), "stage_dedup") + return nil + } + lep6metrics.IncRecheckSubmission(r.ResultClass.String(), "stage_error") + return fmt.Errorf("stage recheck evidence before submit: %w", err) + } + _, err := a.msg.SubmitStorageRecheckEvidence(ctx, c.EpochID, c.TargetAccount, c.TicketID, c.ChallengedTranscriptHash, r.TranscriptHash, r.ResultClass, r.Details) + if err != nil { + // Transient gRPC failures MUST NOT delete the pending row — Wave + // 0 fix. The next tick retries and chain dedup absorbs duplicates. + if chainerrors.IsTransientGrpc(err) { + lep6metrics.IncRecheckSubmission(r.ResultClass.String(), "submit_transient") + return fmt.Errorf("submit recheck evidence (transient, will retry): %w", err) + } + if chainerrors.IsRecheckEvidenceAlreadySubmitted(err) { + lep6metrics.IncRecheckAlreadySubmitted() + lep6metrics.IncRecheckSubmission(r.ResultClass.String(), "already_submitted") + return a.store.MarkRecheckSubmissionSubmitted(ctx, c.EpochID, c.TicketID, c.TargetAccount) + } + _ = a.store.DeletePendingRecheckSubmission(ctx, c.EpochID, c.TicketID, c.TargetAccount) + lep6metrics.IncRecheckSubmission(r.ResultClass.String(), "submit_error") + return err + } + if err := a.store.MarkRecheckSubmissionSubmitted(ctx, c.EpochID, c.TicketID, c.TargetAccount); err != nil { + lep6metrics.IncRecheckSubmission(r.ResultClass.String(), "mark_error") + return err + } + lep6metrics.IncRecheckSubmission(r.ResultClass.String(), "submitted") + return nil +} + +func validRecheckResultClass(cls audittypes.StorageProofResultClass) bool { + switch cls { + case audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_OBSERVER_QUORUM_FAIL, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT: + return true + default: + return false + } +} + +// (Wave 0): isAlreadySubmittedError helper removed; classification is +// centralised in pkg/lumera/chainerrors.IsRecheckEvidenceAlreadySubmitted +// (anchored on the discriminating "recheck evidence already submitted" +// phrase since audittypes.ErrInvalidRecheckEvidence is a generic envelope +// for many distinct rejections), with IsTransientGrpc short-circuit at +// the call site to preserve the pending row across transient failures. diff --git a/supernode/recheck/attestor_test.go b/supernode/recheck/attestor_test.go new file mode 100644 index 00000000..841ad47a --- /dev/null +++ b/supernode/recheck/attestor_test.go @@ -0,0 +1,111 @@ +package recheck + +import ( + "context" + "errors" + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/stretchr/testify/require" +) + +func TestAttestor_SubmitsThenPersists(t *testing.T) { + callSeq = 0 + ctx := context.Background() + store := newMemoryStore() + msg := &recordingAuditMsg{} + a := NewAttestor("self", msg, store) + + candidate := Candidate{EpochID: 7, TargetAccount: "target", TicketID: "ticket-1", ChallengedTranscriptHash: "orig-hash", OriginalReporter: "reporter", OriginalResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH} + result := RecheckResult{TranscriptHash: "recheck-hash", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, Details: "ok"} + + require.NoError(t, a.Submit(ctx, candidate, result)) + require.Len(t, msg.calls, 1) + require.Equal(t, 2, msg.calls[0].callIndex) + require.Less(t, store.recordCallIndex, msg.calls[0].callIndex) + exists, err := store.HasRecheckSubmission(ctx, 7, "ticket-1", "target") + require.NoError(t, err) + require.True(t, exists) +} + +func TestAttestor_DoesNotPersistOnTxFailure(t *testing.T) { + ctx := context.Background() + store := newMemoryStore() + msg := &recordingAuditMsg{err: errBoom} + a := NewAttestor("self", msg, store) + + candidate := Candidate{EpochID: 7, TargetAccount: "target", TicketID: "ticket-1", ChallengedTranscriptHash: "orig-hash", OriginalReporter: "reporter", OriginalResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH} + result := RecheckResult{TranscriptHash: "recheck-hash", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS} + + require.Error(t, a.Submit(ctx, candidate, result)) + exists, err := store.HasRecheckSubmission(ctx, 7, "ticket-1", "target") + require.NoError(t, err) + require.False(t, exists) +} + +func TestAttestor_AcceptsExistingChainRecheckAsIdempotent(t *testing.T) { + ctx := context.Background() + store := newMemoryStore() + msg := &recordingAuditMsg{err: errAlreadyOnChain} + a := NewAttestor("self", msg, store) + + candidate := Candidate{EpochID: 7, TargetAccount: "target", TicketID: "ticket-1", ChallengedTranscriptHash: "orig-hash", OriginalReporter: "reporter", OriginalResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH} + result := RecheckResult{TranscriptHash: "recheck-hash", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL} + + require.NoError(t, a.Submit(ctx, candidate, result)) + exists, err := store.HasRecheckSubmission(ctx, 7, "ticket-1", "target") + require.NoError(t, err) + require.True(t, exists) +} + +func TestAttestor_DoesNotTreatGenericDuplicateWordsAsIdempotent(t *testing.T) { + ctx := context.Background() + store := newMemoryStore() + msg := &recordingAuditMsg{err: errors.New("connection already closed before duplicate retry could be replayed")} + a := NewAttestor("self", msg, store) + + candidate := Candidate{EpochID: 7, TargetAccount: "target", TicketID: "ticket-1", ChallengedTranscriptHash: "orig-hash", OriginalReporter: "reporter", OriginalResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH} + result := RecheckResult{TranscriptHash: "recheck-hash", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL} + + require.Error(t, a.Submit(ctx, candidate, result)) + exists, err := store.HasRecheckSubmission(ctx, 7, "ticket-1", "target") + require.NoError(t, err) + require.False(t, exists) +} + +func TestAttestor_RejectsSelfReportedOrSelfTargetCandidate(t *testing.T) { + ctx := context.Background() + store := newMemoryStore() + msg := &recordingAuditMsg{} + attestor := NewAttestor("self", msg, store) + base := Candidate{EpochID: 7, TargetAccount: "target", TicketID: "ticket-1", ChallengedTranscriptHash: "orig-hash", OriginalReporter: "reporter", OriginalResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH} + result := RecheckResult{TranscriptHash: "recheck-hash", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS} + + selfReporter := base + selfReporter.OriginalReporter = "self" + require.Error(t, attestor.Submit(ctx, selfReporter, result)) + + selfTarget := base + selfTarget.TargetAccount = "self" + require.Error(t, attestor.Submit(ctx, selfTarget, result)) + require.Empty(t, msg.calls) +} + +func TestAttestor_RejectsEmptyRequiredFieldsBeforeTx(t *testing.T) { + ctx := context.Background() + store := newMemoryStore() + msg := &recordingAuditMsg{} + a := NewAttestor("self", msg, store) + + candidate := Candidate{EpochID: 7, TargetAccount: "target", TicketID: "ticket-1", ChallengedTranscriptHash: "orig-hash", OriginalReporter: "reporter", OriginalResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH} + result := RecheckResult{TranscriptHash: "", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS} + + require.Error(t, a.Submit(ctx, candidate, result)) + require.Empty(t, msg.calls) +} + +func TestValidRecheckResultClassAcceptsHashMismatch(t *testing.T) { + if !validRecheckResultClass(audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH) { + t.Fatal("HASH_MISMATCH must pass local validation because Lumera chain accepts it for recheck evidence") + } +} diff --git a/supernode/recheck/eligibility_test.go b/supernode/recheck/eligibility_test.go new file mode 100644 index 00000000..1e58cb0a --- /dev/null +++ b/supernode/recheck/eligibility_test.go @@ -0,0 +1,38 @@ +package recheck + +import ( + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/stretchr/testify/require" +) + +func TestRecheckEligible_AcceptsChainEligibleFailureClasses(t *testing.T) { + for _, cls := range []audittypes.StorageProofResultClass{ + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_OBSERVER_QUORUM_FAIL, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, + } { + require.True(t, IsRecheckEligibleResultClass(cls), cls.String()) + } +} + +func TestRecheckEligible_RejectsPassAndRecheckConfirmedFail(t *testing.T) { + for _, cls := range []audittypes.StorageProofResultClass{ + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_UNSPECIFIED, + } { + require.False(t, IsRecheckEligibleResultClass(cls), cls.String()) + } +} + +func TestMapRecheckOutcome_PreservesSpecFidelity(t *testing.T) { + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, MapRecheckOutcome(OutcomePass)) + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL, MapRecheckOutcome(OutcomeConfirmedHashMismatch)) + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE, MapRecheckOutcome(OutcomeTimeout)) + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_OBSERVER_QUORUM_FAIL, MapRecheckOutcome(OutcomeObserverQuorumFail)) + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, MapRecheckOutcome(OutcomeInvalidTranscript)) +} diff --git a/supernode/recheck/finder.go b/supernode/recheck/finder.go new file mode 100644 index 00000000..d9093f15 --- /dev/null +++ b/supernode/recheck/finder.go @@ -0,0 +1,152 @@ +package recheck + +import ( + "context" + "fmt" + "sort" + "strings" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" +) + +type FinderConfig struct { + LookbackEpochs uint64 + MaxPerTick int +} + +func (c FinderConfig) withDefaults() FinderConfig { + if c.LookbackEpochs == 0 { + c.LookbackEpochs = DefaultLookbackEpochs + } + if c.MaxPerTick <= 0 { + c.MaxPerTick = DefaultMaxPerTick + } + return c +} + +type Finder struct { + audit AuditReader + store Store + reporters ReporterSource + self string + cfg FinderConfig +} + +func NewFinder(audit AuditReader, store Store, self string, cfg FinderConfig) *Finder { + return NewFinderWithReporters(audit, store, self, cfg, NewStaticReporterSource(self)) +} + +func NewFinderWithReporters(audit AuditReader, store Store, self string, cfg FinderConfig, reporters ReporterSource) *Finder { + self = strings.TrimSpace(self) + if reporters == nil { + reporters = NewStaticReporterSource(self) + } + return &Finder{audit: audit, store: store, reporters: reporters, self: self, cfg: cfg.withDefaults()} +} + +func (f *Finder) Find(ctx context.Context) ([]Candidate, error) { + if f.audit == nil || f.store == nil { + return nil, fmt.Errorf("recheck finder missing deps") + } + cur, err := f.audit.GetCurrentEpoch(ctx) + if err != nil { + return nil, fmt.Errorf("current epoch: %w", err) + } + if cur == nil || cur.EpochId == 0 { + return nil, nil + } + start := uint64(1) + if cur.EpochId > f.cfg.LookbackEpochs { + start = cur.EpochId - f.cfg.LookbackEpochs + } + reporters, err := f.reporters.ReporterAccounts(ctx) + if err != nil { + return nil, err + } + out := make([]Candidate, 0, f.cfg.MaxPerTick) + seen := map[string]struct{}{} + for epoch := cur.EpochId; epoch >= start; epoch-- { + results := make([]Candidate, 0) + for _, reporter := range reporters { + rep, err := f.audit.GetEpochReportsByReporter(ctx, reporter, epoch) + if err != nil { + // L4 fix: isolate per-reporter RPC failures. A single + // unreachable reporter must not mask candidates from + // every other reporter in the same epoch — that's the + // silent-coverage-gap path Matee called out. + logtrace.Warn(ctx, "recheck finder: reporter epoch reports unavailable; skipping reporter for this tick", logtrace.Fields{ + logtrace.FieldError: err.Error(), + "reporter": reporter, + "epoch": epoch, + }) + continue + } + if rep == nil { + continue + } + for _, report := range rep.Reports { + results = append(results, candidatesFromReport(epoch, report)...) + } + } + if len(results) == 0 { + if epoch == start { + break + } + continue + } + sort.SliceStable(results, func(i, j int) bool { + if results[i].TicketID == results[j].TicketID { + return results[i].TargetAccount < results[j].TargetAccount + } + return results[i].TicketID < results[j].TicketID + }) + for _, c := range results { + if !c.Valid() || c.TargetAccount == f.self || c.OriginalReporter == f.self { + continue + } + // C2 fix: chain dedup is per-(epoch, ticket, target) — multi- + // target candidates within the same (epoch, ticket) must each + // produce a separate recheck. Key the seen map and the + // HasRecheckSubmission lookup on the full triple. + key := fmt.Sprintf("%d/%s/%s", c.EpochID, c.TicketID, c.TargetAccount) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + done, err := f.store.HasRecheckSubmission(ctx, c.EpochID, c.TicketID, c.TargetAccount) + if err != nil { + return nil, err + } + if done { + continue + } + out = append(out, c) + if len(out) >= f.cfg.MaxPerTick { + return out, nil + } + } + if epoch == 0 || epoch == start { + break + } + } + return out, nil +} + +func candidatesFromReport(epochID uint64, report audittypes.EpochReport) []Candidate { + out := make([]Candidate, 0, len(report.StorageProofResults)) + for _, r := range report.StorageProofResults { + if r == nil { + continue + } + out = append(out, Candidate{ + EpochID: epochID, + TargetAccount: strings.TrimSpace(r.TargetSupernodeAccount), + TicketID: strings.TrimSpace(r.TicketId), + ChallengedTranscriptHash: strings.TrimSpace(r.TranscriptHash), + OriginalReporter: strings.TrimSpace(r.ChallengerSupernodeAccount), + OriginalResultClass: r.ResultClass, + }) + } + return out +} diff --git a/supernode/recheck/finder_service_test.go b/supernode/recheck/finder_service_test.go new file mode 100644 index 00000000..35c78140 --- /dev/null +++ b/supernode/recheck/finder_service_test.go @@ -0,0 +1,140 @@ +package recheck + +import ( + "context" + "testing" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/stretchr/testify/require" +) + +func TestFinder_LookbackLimitDedupAndOrder(t *testing.T) { + store := newMemoryStore() + require.NoError(t, store.RecordRecheckSubmission(context.Background(), 9, "done", "target", "h", "rh", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS)) + a := &stubAudit{current: 10, reports: map[uint64]audittypes.EpochReport{ + 10: {StorageProofResults: []*audittypes.StorageProofResult{ + resFrom("peer", "z", "target-z", "hz", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH), + resFrom("peer", "pass", "target-pass", "hp", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS), + }}, + 9: {StorageProofResults: []*audittypes.StorageProofResult{ + resFrom("peer", "done", "target-done", "hd", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH), + resFrom("peer", "a", "target-a", "ha", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE), + }}, + 2: {StorageProofResults: []*audittypes.StorageProofResult{ + resFrom("peer", "too-old", "target-old", "ho", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH), + }}, + }} + f := NewFinder(a, store, "self", FinderConfig{LookbackEpochs: 2, MaxPerTick: 2}) + got, err := f.Find(context.Background()) + require.NoError(t, err) + require.Len(t, got, 2) + require.Equal(t, uint64(10), got[0].EpochID) + require.Equal(t, "z", got[0].TicketID) + require.Equal(t, uint64(9), got[1].EpochID) + require.Equal(t, "a", got[1].TicketID) +} + +func TestFinder_ScansNetworkReporterSetNotSelfOnly(t *testing.T) { + store := newMemoryStore() + a := &stubAudit{current: 5, reportsBySource: map[string]map[uint64]audittypes.EpochReport{ + "self": { + 5: {StorageProofResults: []*audittypes.StorageProofResult{ + resFrom("other", "self-ticket", "target-self", "h-self", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH), + }}, + }, + "peer-reporter": { + 5: {StorageProofResults: []*audittypes.StorageProofResult{ + {TargetSupernodeAccount: "target-peer", ChallengerSupernodeAccount: "peer-reporter", TicketId: "peer-ticket", TranscriptHash: "h-peer", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE}, + }}, + }, + }} + f := NewFinderWithReporters(a, store, "self", FinderConfig{LookbackEpochs: 1, MaxPerTick: 10}, NewStaticReporterSource("self", "peer-reporter")) + got, err := f.Find(context.Background()) + require.NoError(t, err) + require.Len(t, got, 2) + require.Equal(t, "peer-ticket", got[0].TicketID) + require.Equal(t, "peer-reporter", got[0].OriginalReporter) + require.Equal(t, "self-ticket", got[1].TicketID) +} + +func TestFinder_SkipsSelfTargetCandidate(t *testing.T) { + store := newMemoryStore() + a := &stubAudit{current: 5, reportsBySource: map[string]map[uint64]audittypes.EpochReport{ + "peer-reporter": { + 5: {StorageProofResults: []*audittypes.StorageProofResult{ + resFrom("peer-reporter", "against-self", "self", "h-self", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH), + resFrom("peer-reporter", "against-peer", "target-peer", "h-peer", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH), + }}, + }, + }} + f := NewFinderWithReporters(a, store, "self", FinderConfig{LookbackEpochs: 1, MaxPerTick: 10}, NewStaticReporterSource("peer-reporter")) + got, err := f.Find(context.Background()) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, "against-peer", got[0].TicketID) +} + +func TestFinder_SkipsSelfReportedCandidate(t *testing.T) { + store := newMemoryStore() + a := &stubAudit{current: 5, reportsBySource: map[string]map[uint64]audittypes.EpochReport{ + "self": { + 5: {StorageProofResults: []*audittypes.StorageProofResult{ + resFrom("self", "own-report", "target-peer", "h-own", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH), + resFrom("peer-reporter", "peer-report", "target-peer", "h-peer", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH), + }}, + }, + }} + f := NewFinderWithReporters(a, store, "self", FinderConfig{LookbackEpochs: 1, MaxPerTick: 10}, NewStaticReporterSource("self")) + got, err := f.Find(context.Background()) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, "peer-report", got[0].TicketID) +} + +func TestService_TickModeGateAndSubmit(t *testing.T) { + ctx := context.Background() + store := newMemoryStore() + msg := &recordingAuditMsg{} + a := &stubAudit{current: 10, mode: audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED, reports: map[uint64]audittypes.EpochReport{10: {StorageProofResults: []*audittypes.StorageProofResult{resFrom("peer", "t", "target", "h", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH)}}}} + r := &stubRechecker{result: RecheckResult{TranscriptHash: "rh", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS}} + svc, err := NewService(Config{Enabled: true, TickInterval: time.Millisecond}, a, store, r, NewAttestor("self", msg, store), "self") + require.NoError(t, err) + require.NoError(t, svc.Tick(ctx)) + require.Empty(t, msg.calls) + + a.mode = audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL + require.NoError(t, svc.Tick(ctx)) + require.Len(t, msg.calls, 1) + require.Equal(t, "target", msg.calls[0].target) +} + +func TestService_TickSkipsRecheckWhenFailureBudgetExhausted(t *testing.T) { + ctx := context.Background() + store := newMemoryStore() + store.failures[failureKey(10, "t", "target")] = 2 + msg := &recordingAuditMsg{} + a := &stubAudit{current: 10, mode: audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL, reports: map[uint64]audittypes.EpochReport{10: {StorageProofResults: []*audittypes.StorageProofResult{resFrom("peer", "t", "target", "h", audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH)}}}} + r := &stubRechecker{result: RecheckResult{TranscriptHash: "rh", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS}} + svc, err := NewService(Config{Enabled: true, TickInterval: time.Millisecond, MaxFailureAttemptsPerTicket: 2}, a, store, r, NewAttestor("self", msg, store), "self") + require.NoError(t, err) + + require.NoError(t, svc.Tick(ctx)) + require.Empty(t, r.calls, "recheck execution should be skipped after the per-ticket failure budget is exhausted") + require.Empty(t, msg.calls, "no chain submission should be attempted for a budget-blocked candidate") +} + +func TestConfigDefaults(t *testing.T) { + got := (Config{}).WithDefaults() + require.Equal(t, DefaultLookbackEpochs, got.LookbackEpochs) + require.Equal(t, DefaultMaxPerTick, got.MaxPerTick) + require.Equal(t, DefaultTickInterval, got.TickInterval) +} + +func res(ticket, target, transcript string, class audittypes.StorageProofResultClass) *audittypes.StorageProofResult { + return resFrom("self", ticket, target, transcript, class) +} + +func resFrom(reporter, ticket, target, transcript string, class audittypes.StorageProofResultClass) *audittypes.StorageProofResult { + return &audittypes.StorageProofResult{TargetSupernodeAccount: target, ChallengerSupernodeAccount: reporter, TicketId: ticket, TranscriptHash: transcript, ResultClass: class} +} diff --git a/supernode/recheck/reporters.go b/supernode/recheck/reporters.go new file mode 100644 index 00000000..980c5af4 --- /dev/null +++ b/supernode/recheck/reporters.go @@ -0,0 +1,68 @@ +package recheck + +import ( + "context" + "fmt" + "sort" + "strings" + + supernodemodule "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/supernode" +) + +type staticReporterSource struct { + accounts []string +} + +func NewStaticReporterSource(accounts ...string) ReporterSource { + return staticReporterSource{accounts: accounts} +} + +func (s staticReporterSource) ReporterAccounts(ctx context.Context) ([]string, error) { + return normalizeAccounts(s.accounts), nil +} + +type SupernodeReporterSource struct { + module supernodemodule.Module + self string +} + +func NewSupernodeReporterSource(module supernodemodule.Module, self string) *SupernodeReporterSource { + return &SupernodeReporterSource{module: module, self: strings.TrimSpace(self)} +} + +func (s *SupernodeReporterSource) ReporterAccounts(ctx context.Context) ([]string, error) { + if s == nil || s.module == nil { + return nil, fmt.Errorf("recheck reporter source missing supernode module") + } + resp, err := s.module.ListSuperNodes(ctx) + if err != nil { + return nil, fmt.Errorf("list supernodes: %w", err) + } + accounts := []string{s.self} + if resp != nil { + for _, sn := range resp.Supernodes { + if sn != nil { + accounts = append(accounts, sn.SupernodeAccount) + } + } + } + return normalizeAccounts(accounts), nil +} + +func normalizeAccounts(accounts []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(accounts)) + for _, account := range accounts { + account = strings.TrimSpace(account) + if account == "" { + continue + } + if _, ok := seen[account]; ok { + continue + } + seen[account] = struct{}{} + out = append(out, account) + } + sort.Strings(out) + return out +} diff --git a/supernode/recheck/service.go b/supernode/recheck/service.go new file mode 100644 index 00000000..0f71f551 --- /dev/null +++ b/supernode/recheck/service.go @@ -0,0 +1,141 @@ +package recheck + +import ( + "context" + "fmt" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" +) + +type Config struct { + Enabled bool + LookbackEpochs uint64 + MaxPerTick int + TickInterval time.Duration + Jitter time.Duration + MaxFailureAttemptsPerTicket int + FailureBackoffTTL time.Duration +} + +func (c Config) WithDefaults() Config { + if c.LookbackEpochs == 0 { + c.LookbackEpochs = DefaultLookbackEpochs + } + if c.MaxPerTick <= 0 { + c.MaxPerTick = DefaultMaxPerTick + } + if c.TickInterval <= 0 { + c.TickInterval = DefaultTickInterval + } + if c.Jitter < 0 { + c.Jitter = 0 + } + if c.MaxFailureAttemptsPerTicket <= 0 { + c.MaxFailureAttemptsPerTicket = DefaultMaxFailureAttemptsPerTicket + } + if c.FailureBackoffTTL <= 0 { + c.FailureBackoffTTL = DefaultFailureBackoffTTL + } + return c +} + +type Service struct { + cfg Config + audit AuditReader + store Store + finder *Finder + rechecker Rechecker + attestor *Attestor +} + +func NewService(cfg Config, audit AuditReader, store Store, rechecker Rechecker, attestor *Attestor, self string) (*Service, error) { + return NewServiceWithReporters(cfg, audit, store, rechecker, attestor, self, NewStaticReporterSource(self)) +} + +func NewServiceWithReporters(cfg Config, audit AuditReader, store Store, rechecker Rechecker, attestor *Attestor, self string, reporters ReporterSource) (*Service, error) { + cfg = cfg.WithDefaults() + if audit == nil || store == nil || attestor == nil || rechecker == nil || reporters == nil { + return nil, fmt.Errorf("recheck service missing deps") + } + finder := NewFinderWithReporters(audit, store, self, FinderConfig{LookbackEpochs: cfg.LookbackEpochs, MaxPerTick: cfg.MaxPerTick}, reporters) + return &Service{cfg: cfg, audit: audit, store: store, finder: finder, rechecker: rechecker, attestor: attestor}, nil +} + +func (s *Service) Run(ctx context.Context) error { + if !s.cfg.Enabled { + <-ctx.Done() + return nil + } + if s.cfg.Jitter > 0 { + select { + case <-time.After(s.cfg.Jitter): + case <-ctx.Done(): + return nil + } + } + if err := s.Tick(ctx); err != nil { + logtrace.Warn(ctx, "lep6 recheck: tick failed", logtrace.Fields{"error": err.Error()}) + } + t := time.NewTicker(s.cfg.TickInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-t.C: + if err := s.Tick(ctx); err != nil { + logtrace.Warn(ctx, "lep6 recheck: tick failed", logtrace.Fields{"error": err.Error()}) + } + } + } +} + +func (s *Service) Tick(ctx context.Context) error { + if !s.cfg.Enabled { + return nil + } + params, err := s.audit.GetParams(ctx) + if err != nil { + return fmt.Errorf("params: %w", err) + } + if params == nil || params.Params.StorageTruthEnforcementMode == audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED { + return nil + } + candidates, err := s.finder.Find(ctx) + if err != nil { + return err + } + lep6metrics.SetRecheckPendingCandidates(len(candidates)) + _ = s.store.PurgeExpiredRecheckAttemptFailures(ctx) + for _, c := range candidates { + lep6metrics.IncRecheckCandidateFound() + if err := ctx.Err(); err != nil { + return nil + } + blocked, err := s.store.HasRecheckAttemptFailureBudgetExceeded(ctx, c.EpochID, c.TicketID, c.TargetAccount, s.cfg.MaxFailureAttemptsPerTicket) + if err != nil { + logtrace.Warn(ctx, "lep6 recheck: failure budget lookup failed", logtrace.Fields{"epoch_id": c.EpochID, "ticket_id": c.TicketID, "error": err.Error()}) + continue + } + if blocked { + logtrace.Warn(ctx, "lep6 recheck: skipping candidate after failure budget exhausted", logtrace.Fields{"epoch_id": c.EpochID, "ticket_id": c.TicketID}) + continue + } + result, err := s.rechecker.Recheck(ctx, c) + if err != nil { + _ = s.store.RecordRecheckAttemptFailure(ctx, c.EpochID, c.TicketID, c.TargetAccount, err, s.cfg.FailureBackoffTTL) + lep6metrics.IncRecheckFailure("execute") + logtrace.Warn(ctx, "lep6 recheck: execution failed", logtrace.Fields{"epoch_id": c.EpochID, "ticket_id": c.TicketID, "error": err.Error()}) + continue + } + if err := s.attestor.Submit(ctx, c, result); err != nil { + _ = s.store.RecordRecheckAttemptFailure(ctx, c.EpochID, c.TicketID, c.TargetAccount, err, s.cfg.FailureBackoffTTL) + lep6metrics.IncRecheckFailure("submit") + logtrace.Warn(ctx, "lep6 recheck: submit failed", logtrace.Fields{"epoch_id": c.EpochID, "ticket_id": c.TicketID, "error": err.Error()}) + } + } + return nil +} diff --git a/supernode/recheck/test_helpers_test.go b/supernode/recheck/test_helpers_test.go new file mode 100644 index 00000000..970656f4 --- /dev/null +++ b/supernode/recheck/test_helpers_test.go @@ -0,0 +1,132 @@ +package recheck + +import ( + "context" + "errors" + "fmt" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + sdktx "github.com/cosmos/cosmos-sdk/types/tx" +) + +var ( + errBoom = errors.New("boom") + errAlreadyOnChain = errors.New("invalid recheck evidence: recheck evidence already submitted for epoch 7 ticket \"ticket-1\" by \"self\"") +) + +var callSeq int + +type memoryStore struct { + seen map[string]bool + failures map[string]int + recordCallIndex int +} + +func newMemoryStore() *memoryStore { + return &memoryStore{seen: map[string]bool{}, failures: map[string]int{}} +} +func (m *memoryStore) HasRecheckSubmission(_ context.Context, epochID uint64, ticketID, targetAccount string) (bool, error) { + return m.seen[key(epochID, ticketID, targetAccount)], nil +} +func (m *memoryStore) RecordPendingRecheckSubmission(_ context.Context, epochID uint64, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash string, resultClass audittypes.StorageProofResultClass) error { + callSeq++ + m.recordCallIndex = callSeq + k := key(epochID, ticketID, targetAccount) + if m.seen[k] { + // Match production SQLite ON CONFLICT DO NOTHING semantics. + return nil + } + m.seen[k] = true + return nil +} +func (m *memoryStore) MarkRecheckSubmissionSubmitted(_ context.Context, epochID uint64, ticketID, targetAccount string) error { + m.seen[key(epochID, ticketID, targetAccount)] = true + return nil +} +func (m *memoryStore) DeletePendingRecheckSubmission(_ context.Context, epochID uint64, ticketID, targetAccount string) error { + delete(m.seen, key(epochID, ticketID, targetAccount)) + return nil +} +func (m *memoryStore) RecordRecheckSubmission(_ context.Context, epochID uint64, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash string, resultClass audittypes.StorageProofResultClass) error { + callSeq++ + m.recordCallIndex = callSeq + m.seen[key(epochID, ticketID, targetAccount)] = true + return nil +} +func (m *memoryStore) RecordRecheckAttemptFailure(_ context.Context, epochID uint64, ticketID, targetAccount string, err error, ttl time.Duration) error { + m.failures[failureKey(epochID, ticketID, targetAccount)]++ + return nil +} +func (m *memoryStore) HasRecheckAttemptFailureBudgetExceeded(_ context.Context, epochID uint64, ticketID, targetAccount string, maxAttempts int) (bool, error) { + return maxAttempts > 0 && m.failures[failureKey(epochID, ticketID, targetAccount)] >= maxAttempts, nil +} +func (m *memoryStore) PurgeExpiredRecheckAttemptFailures(_ context.Context) error { return nil } +func key(epochID uint64, ticketID, targetAccount string) string { + return fmt.Sprintf("%d/%s/%s", epochID, ticketID, targetAccount) +} +func failureKey(epochID uint64, ticketID, targetAccount string) string { + return fmt.Sprintf("%d/%s/%s", epochID, ticketID, targetAccount) +} + +type recordingAuditMsg struct { + calls []submitCall + err error +} +type submitCall struct { + callIndex int + epochID uint64 + target, ticket, challenged, recheck string + class audittypes.StorageProofResultClass + details string +} + +func (m *recordingAuditMsg) SubmitStorageRecheckEvidence(ctx context.Context, epochID uint64, challengedSupernodeAccount, ticketID, challengedResultTranscriptHash, recheckTranscriptHash string, recheckResultClass audittypes.StorageProofResultClass, details string) (*sdktx.BroadcastTxResponse, error) { + callSeq++ + m.calls = append(m.calls, submitCall{callIndex: callSeq, epochID: epochID, target: challengedSupernodeAccount, ticket: ticketID, challenged: challengedResultTranscriptHash, recheck: recheckTranscriptHash, class: recheckResultClass, details: details}) + if m.err != nil { + return nil, m.err + } + return &sdktx.BroadcastTxResponse{}, nil +} + +type stubAudit struct { + current uint64 + reports map[uint64]audittypes.EpochReport + reportsBySource map[string]map[uint64]audittypes.EpochReport + mode audittypes.StorageTruthEnforcementMode +} + +func (s *stubAudit) GetCurrentEpoch(ctx context.Context) (*audittypes.QueryCurrentEpochResponse, error) { + return &audittypes.QueryCurrentEpochResponse{EpochId: s.current}, nil +} +func (s *stubAudit) GetEpochReport(ctx context.Context, epochID uint64, supernodeAccount string) (*audittypes.QueryEpochReportResponse, error) { + return &audittypes.QueryEpochReportResponse{Report: s.reports[epochID]}, nil +} +func (s *stubAudit) GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*audittypes.QueryEpochReportsByReporterResponse, error) { + if s.reportsBySource != nil { + if byEpoch, ok := s.reportsBySource[reporterAccount]; ok { + if report, ok := byEpoch[epochID]; ok { + return &audittypes.QueryEpochReportsByReporterResponse{Reports: []audittypes.EpochReport{report}}, nil + } + } + } + if reporterAccount == "self" { + return &audittypes.QueryEpochReportsByReporterResponse{Reports: []audittypes.EpochReport{s.reports[epochID]}}, nil + } + return &audittypes.QueryEpochReportsByReporterResponse{}, nil +} +func (s *stubAudit) GetParams(ctx context.Context) (*audittypes.QueryParamsResponse, error) { + return &audittypes.QueryParamsResponse{Params: audittypes.Params{StorageTruthEnforcementMode: s.mode}}, nil +} + +type stubRechecker struct { + result RecheckResult + calls []Candidate + err error +} + +func (s *stubRechecker) Recheck(ctx context.Context, c Candidate) (RecheckResult, error) { + s.calls = append(s.calls, c) + return s.result, s.err +} diff --git a/supernode/recheck/types.go b/supernode/recheck/types.go new file mode 100644 index 00000000..9f916c61 --- /dev/null +++ b/supernode/recheck/types.go @@ -0,0 +1,110 @@ +package recheck + +import ( + "context" + "strings" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +const ( + DefaultLookbackEpochs = uint64(7) + DefaultMaxPerTick = 5 + DefaultTickInterval = time.Minute + DefaultMaxFailureAttemptsPerTicket = 3 + DefaultFailureBackoffTTL = 15 * time.Minute +) + +type Outcome int + +const ( + OutcomePass Outcome = iota + OutcomeConfirmedHashMismatch + OutcomeTimeout + OutcomeObserverQuorumFail + OutcomeInvalidTranscript +) + +type Candidate struct { + EpochID uint64 + TargetAccount string + TicketID string + ChallengedTranscriptHash string + OriginalReporter string + OriginalResultClass audittypes.StorageProofResultClass +} + +type RecheckResult struct { + TranscriptHash string + ResultClass audittypes.StorageProofResultClass + Details string +} + +type Store interface { + HasRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount string) (bool, error) + RecordPendingRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash string, resultClass audittypes.StorageProofResultClass) error + MarkRecheckSubmissionSubmitted(ctx context.Context, epochID uint64, ticketID, targetAccount string) error + DeletePendingRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount string) error + RecordRecheckSubmission(ctx context.Context, epochID uint64, ticketID, targetAccount, challengedTranscriptHash, recheckTranscriptHash string, resultClass audittypes.StorageProofResultClass) error + RecordRecheckAttemptFailure(ctx context.Context, epochID uint64, ticketID, targetAccount string, err error, ttl time.Duration) error + HasRecheckAttemptFailureBudgetExceeded(ctx context.Context, epochID uint64, ticketID, targetAccount string, maxAttempts int) (bool, error) + PurgeExpiredRecheckAttemptFailures(ctx context.Context) error +} + +type AuditReader interface { + GetCurrentEpoch(ctx context.Context) (*audittypes.QueryCurrentEpochResponse, error) + GetEpochReport(ctx context.Context, epochID uint64, supernodeAccount string) (*audittypes.QueryEpochReportResponse, error) + GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*audittypes.QueryEpochReportsByReporterResponse, error) + GetParams(ctx context.Context) (*audittypes.QueryParamsResponse, error) +} + +type ReporterSource interface { + ReporterAccounts(ctx context.Context) ([]string, error) +} + +type Rechecker interface { + Recheck(ctx context.Context, candidate Candidate) (RecheckResult, error) +} + +func IsRecheckEligibleResultClass(cls audittypes.StorageProofResultClass) bool { + switch cls { + case audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_OBSERVER_QUORUM_FAIL, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT: + return true + default: + return false + } +} + +// MapRecheckOutcome translates the local recheck verifier outcome into the +// chain result class submitted via MsgSubmitStorageRecheckEvidence. A locally +// confirmed hash mismatch intentionally maps to RECHECK_CONFIRMED_FAIL rather +// than re-emitting HASH_MISMATCH: Lumera chain accepts both classes in +// x/audit/v1/keeper/msg_storage_truth.go, and scoring deliberately gives the +// recheck-confirmed class its own impact bucket in +// x/audit/v1/keeper/storage_truth_scoring.go:492-541. Keeping the conversion +// here makes the supernode submission match the chain's second-stage evidence +// semantics without changing first-stage storage-proof report semantics. +func MapRecheckOutcome(outcome Outcome) audittypes.StorageProofResultClass { + switch outcome { + case OutcomePass: + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS + case OutcomeConfirmedHashMismatch: + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL + case OutcomeTimeout: + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE + case OutcomeObserverQuorumFail: + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_OBSERVER_QUORUM_FAIL + case OutcomeInvalidTranscript: + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT + default: + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT + } +} + +func (c Candidate) Valid() bool { + return c.EpochID > 0 && strings.TrimSpace(c.TargetAccount) != "" && strings.TrimSpace(c.TicketID) != "" && strings.TrimSpace(c.ChallengedTranscriptHash) != "" && strings.TrimSpace(c.OriginalReporter) != "" && IsRecheckEligibleResultClass(c.OriginalResultClass) +} diff --git a/supernode/recheck/wave1_regression_test.go b/supernode/recheck/wave1_regression_test.go new file mode 100644 index 00000000..fad32565 --- /dev/null +++ b/supernode/recheck/wave1_regression_test.go @@ -0,0 +1,100 @@ +package recheck + +import ( + "context" + "errors" + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/stretchr/testify/require" +) + +// TestAttestor_MultiTargetSameTicketBothPersist is the Wave 1 / C2 +// regression test Matee called out: two distinct targets within the same +// (epoch, ticket) must each produce a persisted dedup row and a chain +// submit. The previous PK collapsed both into one row and dropped the +// second submit. +func TestAttestor_MultiTargetSameTicketBothPersist(t *testing.T) { + callSeq = 0 + ctx := context.Background() + store := newMemoryStore() + msg := &recordingAuditMsg{} + a := NewAttestor("self", msg, store) + + mk := func(target string) Candidate { + return Candidate{ + EpochID: 7, + TargetAccount: target, + TicketID: "ticket-1", + ChallengedTranscriptHash: "orig-hash", + OriginalReporter: "reporter", + OriginalResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, + } + } + result := RecheckResult{TranscriptHash: "recheck-hash", ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, Details: "ok"} + + require.NoError(t, a.Submit(ctx, mk("target-a"), result)) + require.NoError(t, a.Submit(ctx, mk("target-b"), result)) + + // Both targets must be persisted in the dedup store. + exA, err := store.HasRecheckSubmission(ctx, 7, "ticket-1", "target-a") + require.NoError(t, err) + require.True(t, exA, "target-a must be persisted") + exB, err := store.HasRecheckSubmission(ctx, 7, "ticket-1", "target-b") + require.NoError(t, err) + require.True(t, exB, "target-b must be persisted (C2 regression)") + + // Both must have produced a chain submit. + require.Len(t, msg.calls, 2) + require.NotEqual(t, msg.calls[0].target, msg.calls[1].target) +} + +// fakeReporterErrAudit is a stub that fails for "reporter-bad" and returns +// candidates for "reporter-good". +type fakeReporterErrAudit struct { + stubAudit +} + +func (f *fakeReporterErrAudit) GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*audittypes.QueryEpochReportsByReporterResponse, error) { + if reporterAccount == "reporter-bad" { + return nil, errors.New("rpc unavailable") + } + if epochID != f.current { + return &audittypes.QueryEpochReportsByReporterResponse{}, nil + } + report := audittypes.EpochReport{ + StorageProofResults: []*audittypes.StorageProofResult{ + { + ChallengerSupernodeAccount: "reporter-good", + TargetSupernodeAccount: "target", + TicketId: "ticket-good", + TranscriptHash: "h", + ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, + }, + }, + } + return &audittypes.QueryEpochReportsByReporterResponse{Reports: []audittypes.EpochReport{report}}, nil +} + +// staticTwoReporters returns both reporters so the finder iterates them. +type staticTwoReporters struct{} + +func (staticTwoReporters) ReporterAccounts(_ context.Context) ([]string, error) { + return []string{"reporter-bad", "reporter-good"}, nil +} + +// TestFinder_PerReporterErrorIsolation is the Wave 1 / L4 regression: a +// single failing reporter RPC must NOT mask candidates from other +// reporters. +func TestFinder_PerReporterErrorIsolation(t *testing.T) { + ctx := context.Background() + a := &fakeReporterErrAudit{stubAudit: stubAudit{current: 5, mode: audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL}} + store := newMemoryStore() + + f := NewFinderWithReporters(a, store, "self", FinderConfig{LookbackEpochs: 1, MaxPerTick: 10}, staticTwoReporters{}) + out, err := f.Find(ctx) + require.NoError(t, err, "per-reporter error must not propagate") + require.Len(t, out, 1) + require.Equal(t, "reporter-good", out[0].OriginalReporter) + require.Equal(t, "ticket-good", out[0].TicketID) +} diff --git a/supernode/self_healing/cascade_fake_test.go b/supernode/self_healing/cascade_fake_test.go new file mode 100644 index 00000000..267ea095 --- /dev/null +++ b/supernode/self_healing/cascade_fake_test.go @@ -0,0 +1,114 @@ +package self_healing + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "sync/atomic" + + cascadeService "github.com/LumeraProtocol/supernode/v2/supernode/cascade" +) + +// fakeCascadeFactory.NewCascadeRegistrationTask returns a programmable +// fakeCascadeTask. The healer flow exercises only RecoveryReseed; the +// finalizer only PublishStagedArtefacts. Other methods panic — a regression +// that calls Register/Download in the heal path is loud. +type fakeCascadeFactory struct { + mu sync.Mutex + reseedFn func(ctx context.Context, req *cascadeService.RecoveryReseedRequest) (*cascadeService.RecoveryReseedResult, error) + publishFn func(ctx context.Context, stagingDir string) error + publishCalls atomic.Int64 + reseedCalls atomic.Int64 + lastPublishedDir atomic.Value // string +} + +func newFakeCascadeFactory() *fakeCascadeFactory { + f := &fakeCascadeFactory{} + f.lastPublishedDir.Store("") + return f +} + +func (f *fakeCascadeFactory) NewCascadeRegistrationTask() cascadeService.CascadeTask { + return &fakeCascadeTask{f: f} +} + +type fakeCascadeTask struct { + f *fakeCascadeFactory +} + +func (t *fakeCascadeTask) Register(ctx context.Context, req *cascadeService.RegisterRequest, send func(resp *cascadeService.RegisterResponse) error) error { + panic("self_healing test: cascade Register must not be called") +} +func (t *fakeCascadeTask) Download(ctx context.Context, req *cascadeService.DownloadRequest, send func(resp *cascadeService.DownloadResponse) error) error { + panic("self_healing test: cascade Download must not be called") +} +func (t *fakeCascadeTask) CleanupDownload(ctx context.Context, tmpDir string) error { return nil } + +func (t *fakeCascadeTask) RecoveryReseed(ctx context.Context, req *cascadeService.RecoveryReseedRequest) (*cascadeService.RecoveryReseedResult, error) { + t.f.reseedCalls.Add(1) + t.f.mu.Lock() + fn := t.f.reseedFn + t.f.mu.Unlock() + if fn == nil { + return nil, errors.New("fakeCascade: no reseedFn configured") + } + return fn(ctx, req) +} + +func (t *fakeCascadeTask) PublishStagedArtefacts(ctx context.Context, stagingDir string) error { + t.f.publishCalls.Add(1) + t.f.lastPublishedDir.Store(stagingDir) + t.f.mu.Lock() + fn := t.f.publishFn + t.f.mu.Unlock() + if fn == nil { + return nil + } + return fn(ctx, stagingDir) +} + +// makeStagingDir creates an empty staging dir + minimal manifest+reconstructed +// file pair the §19 transport expects. Useful for finalizer tests that don't +// drive the full RecoveryReseed. +func makeStagingDir(t testing_T, root string, opID uint64, hashB64 string, body []byte) string { + dir := filepath.Join(root, itoa(opID)) + mustMkdir(t, dir) + mustMkdir(t, filepath.Join(dir, "symbols")) + mustWrite(t, filepath.Join(dir, "reconstructed.bin"), body) + manifest := []byte(`{"action_id":"ticket-` + itoa(opID) + `","layout":{"blocks":[]},"id_files":[],"symbol_keys":[],"symbols_dir":"` + filepath.Join(dir, "symbols") + `","reconstructed_rel":"reconstructed.bin","manifest_hash_b64":"` + hashB64 + `"}`) + mustWrite(t, filepath.Join(dir, "manifest.json"), manifest) + return dir +} + +// minimal testing.T-like surface so test helpers can be reused without +// importing testing.B. +type testing_T interface { + Helper() + Fatalf(format string, args ...interface{}) +} + +func mustMkdir(t testing_T, p string) { + if err := os.MkdirAll(p, 0o700); err != nil { + t.Helper() + t.Fatalf("mkdir %q: %v", p, err) + } +} +func mustWrite(t testing_T, p string, b []byte) { + if err := os.WriteFile(p, b, 0o600); err != nil { + t.Helper() + t.Fatalf("write %q: %v", p, err) + } +} +func itoa(u uint64) string { + if u == 0 { + return "0" + } + digits := []byte{} + for u > 0 { + digits = append([]byte{byte('0' + u%10)}, digits...) + u /= 10 + } + return string(digits) +} diff --git a/supernode/self_healing/finalizer.go b/supernode/self_healing/finalizer.go new file mode 100644 index 00000000..129d1abb --- /dev/null +++ b/supernode/self_healing/finalizer.go @@ -0,0 +1,126 @@ +package self_healing + +import ( + "context" + "fmt" + "os" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/chainerrors" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" + "github.com/LumeraProtocol/supernode/v2/pkg/storage/queries" +) + +// finalizeClaim runs LEP-6 §19 Phase 3 for one persisted heal-op claim. +// +// Possible chain states for a claim row whose heal_op_id is queried: +// - SCHEDULED / IN_PROGRESS — chain has not yet recorded the healer's +// claim. Treat as transient; do nothing this tick. +// - HEALER_REPORTED — claim recorded but quorum not yet reached. No-op. +// - VERIFIED — quorum reached; publish staging dir to KAD via +// cascadeService.PublishStagedArtefacts, then delete the dir + the +// dedup row. +// - FAILED — verifiers rejected the claim or the chain finalized +// negatively. Delete staging dir + dedup row; do NOT publish (Scenario +// B). Chain has already applied §20 penalties. +// - EXPIRED — deadline passed before quorum (Scenario C, late-detected). +// Same handling as FAILED on the supernode side. +// - GetHealOp errors with not-found — treat as EXPIRED (chain may have +// pruned), delete staging. +func (s *Service) finalizeClaim(ctx context.Context, claim queries.HealClaimRecord) error { + resp, err := s.lumera.Audit().GetHealOp(ctx, claim.HealOpID) + if err != nil { + // Transient gRPC failures MUST NOT trigger destructive cleanup — + // Wave 0 fix for C4. The previous implementation matched any + // "not found" / "not_found" substring including gRPC NotFound on + // blocks, codec lookup misses, and key-not-found errors, all of + // which would wipe healer staging dirs. + if chainerrors.IsTransientGrpc(err) { + return fmt.Errorf("get heal op (transient, will retry): %w", err) + } + if chainerrors.IsHealOpNotFound(err) { + logtrace.Warn(ctx, "self_healing(LEP-6): heal-op not found on chain; cleaning abandoned claim", logtrace.Fields{ + logtrace.FieldError: err.Error(), + "heal_op_id": claim.HealOpID, + "staging_dir": claim.StagingDir, + }) + return s.cleanupClaim(ctx, claim, audittypes.HealOpStatus_HEAL_OP_STATUS_EXPIRED) + } + // Defensive: don't blow away local state on transient query errors. + // A persistent error is logged by the caller; row will be retried + // next tick. + return fmt.Errorf("get heal op: %w", err) + } + if resp == nil { + return fmt.Errorf("nil heal op response") + } + op := resp.HealOp + switch op.Status { + case audittypes.HealOpStatus_HEAL_OP_STATUS_VERIFIED: + return s.publishStagingDir(ctx, claim) + case audittypes.HealOpStatus_HEAL_OP_STATUS_FAILED, + audittypes.HealOpStatus_HEAL_OP_STATUS_EXPIRED: + return s.cleanupClaim(ctx, claim, op.Status) + default: + // SCHEDULED / IN_PROGRESS / HEALER_REPORTED — quorum pending. + return nil + } +} + +func (s *Service) publishStagingDir(ctx context.Context, claim queries.HealClaimRecord) error { + if err := s.semPublish.Acquire(ctx, 1); err != nil { + return err + } + defer s.semPublish.Release(1) + + task := s.cascadeFactory.NewCascadeRegistrationTask() + if err := task.PublishStagedArtefacts(ctx, claim.StagingDir); err != nil { + // Leave row + staging in place; next tick retries publish. Chain + // has already recorded VERIFIED so no on-chain work pending. + return fmt.Errorf("publish staged artefacts: %w", err) + } + if err := s.store.DeleteHealClaim(ctx, claim.HealOpID); err != nil { + return fmt.Errorf("delete heal claim row: %w", err) + } + if err := os.RemoveAll(claim.StagingDir); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): staging cleanup after publish failed", logtrace.Fields{ + logtrace.FieldError: err.Error(), + "heal_op_id": claim.HealOpID, + "staging_dir": claim.StagingDir, + }) + } + lep6metrics.IncHealFinalizePublish() + logtrace.Info(ctx, "self_healing(LEP-6): published staged artefacts to KAD", logtrace.Fields{ + "heal_op_id": claim.HealOpID, + "ticket_id": claim.TicketID, + "staging_dir": claim.StagingDir, + }) + return nil +} + +func (s *Service) cleanupClaim(ctx context.Context, claim queries.HealClaimRecord, status audittypes.HealOpStatus) error { + if err := os.RemoveAll(claim.StagingDir); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): staging cleanup failed", logtrace.Fields{ + logtrace.FieldError: err.Error(), + "heal_op_id": claim.HealOpID, + "status": status.String(), + }) + } + if err := s.store.DeleteHealClaim(ctx, claim.HealOpID); err != nil { + return fmt.Errorf("delete heal claim row: %w", err) + } + lep6metrics.IncHealFinalizeCleanup(status.String()) + logtrace.Info(ctx, "self_healing(LEP-6): claim cleaned up (no publish)", logtrace.Fields{ + "heal_op_id": claim.HealOpID, + "status": status.String(), + }) + return nil +} + +// (Wave 0): isChainHealOpNotFound helper removed; classification is +// centralised in pkg/lumera/chainerrors.IsHealOpNotFound which uses typed +// sentinel matching (audittypes.ErrHealOpNotFound) plus a discriminating +// gRPC codes.NotFound + "heal op not found" anchor to avoid the broad +// "not found" / "not_found" trap that previously caused destructive +// cleanup on transient query failures (e.g. "block N not found"). diff --git a/supernode/self_healing/healer.go b/supernode/self_healing/healer.go new file mode 100644 index 00000000..9258f09f --- /dev/null +++ b/supernode/self_healing/healer.go @@ -0,0 +1,363 @@ +package self_healing + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/chainerrors" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" + "github.com/LumeraProtocol/supernode/v2/pkg/storage/queries" + cascadeService "github.com/LumeraProtocol/supernode/v2/supernode/cascade" +) + +// reconstructAndClaim runs LEP-6 §19 Phase 1 for one heal-op. +// +// Steps: +// +// 1. Acquire semReconstruct (RAM cap; RaptorQ is heavy). +// 2. cascadeService.RecoveryReseed(PersistArtifacts=false, StagingDir=…) — +// reconstructs the file, verifies hash against Action.DataHash, +// regenerates RQ artefacts, STAGES to disk. NO KAD publish. +// 3. Submit MsgClaimHealComplete{HealManifestHash} FIRST. Submit-then- +// persist ordering: if submit fails (mempool, signing, chain-rejected) +// no SQLite row is left; next tick retries cleanly. +// 4. On chain acceptance, persist (heal_op_id, ticket_id, manifest_hash, +// staging_dir) to heal_claims_submitted so finalizer can drive the op. +// +// Crash-recovery path: if submit succeeded but persist crashed, the next +// tick's dispatchHealerOps sees the chain has moved past SCHEDULED (or +// the resubmit fails with "does not accept healer completion claim"). We +// reconcile via reconcileExistingClaim — query GetHealOp; if status ∈ +// {HEALER_REPORTED, VERIFIED, FAILED, EXPIRED} and ResultHash matches +// the manifest we just rebuilt, persist the dedup row and let finalizer +// take over. +func (s *Service) reconstructAndClaim(ctx context.Context, op audittypes.HealOp) error { + if err := s.semReconstruct.Acquire(ctx, 1); err != nil { + return err + } + defer s.semReconstruct.Release(1) + + stagingDir := filepath.Join(s.cfg.StagingRoot, fmt.Sprintf("%d", op.HealOpId)) + if err := os.MkdirAll(stagingDir, 0o700); err != nil { + return fmt.Errorf("mkdir staging: %w", err) + } + + task := s.cascadeFactory.NewCascadeRegistrationTask() + res, err := task.RecoveryReseed(ctx, &cascadeService.RecoveryReseedRequest{ + ActionID: op.TicketId, + PersistArtifacts: false, + StagingDir: stagingDir, + }) + if err != nil { + // Reconstruction failed (Scenario C). Per LEP-6, healer simply does + // not submit ClaimHealComplete; chain will EXPIRE the op at deadline. + // Clean staging dir; nothing to publish. + _ = os.RemoveAll(stagingDir) + return fmt.Errorf("recovery reseed: %w", err) + } + if !res.DataHashVerified { + _ = os.RemoveAll(stagingDir) + return fmt.Errorf("data hash not verified") + } + manifestHash := strings.TrimSpace(res.ReconstructedHashB64) + if manifestHash == "" { + _ = os.RemoveAll(stagingDir) + return fmt.Errorf("empty manifest hash") + } + + // Pre-stage before chain submit. This closes the restart window where the + // tx is accepted but the process dies before recording local dedup state; + // on restart, the pending row prevents a duplicate submit loop and lets + // finalizer/reconciliation continue from local durable state. + if err := s.store.RecordPendingHealClaim(ctx, op.HealOpId, op.TicketId, manifestHash, stagingDir); err != nil { + if errors.Is(err, queries.ErrLEP6ClaimAlreadyRecorded) { + lep6metrics.IncHealClaim("dedup") + return nil + } + _ = os.RemoveAll(stagingDir) + lep6metrics.IncHealClaim("stage_error") + return fmt.Errorf("stage heal claim before submit: %w", err) + } + + // H1 fix: pre-check deadline before fee-burning submit. RaptorQ + + // VerifierFetchAttempts × VerifierFetchTimeout can take minutes, + // during which the deadline epoch may pass. Chain rejects past- + // deadline submits via ErrHealOpInvalidState, but we'd still pay the + // gas to find that out — and the dispatcher would retry every poll + // until the chain status flipped. Save the fee + the staging cleanup + // loop by checking GetCurrentEpoch first. + if expired, expErr := s.healOpDeadlinePassed(ctx, op); expErr != nil { + // Couldn't determine deadline — let the submit attempt proceed + // (chain will reject if needed). Don't block on a transient + // query failure. + logtrace.Warn(ctx, "self_healing(LEP-6): could not check deadline before submit; proceeding", logtrace.Fields{ + "heal_op_id": op.HealOpId, + logtrace.FieldError: expErr.Error(), + }) + } else if expired { + _ = s.store.DeletePendingHealClaim(ctx, op.HealOpId) + _ = os.RemoveAll(stagingDir) + lep6metrics.IncHealClaim("deadline_skipped") + logtrace.Warn(ctx, "self_healing(LEP-6): heal op deadline passed before submit; skipping", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "deadline": op.DeadlineEpochId, + "staging_dir": stagingDir, + }) + return nil + } + + if _, err := s.lumera.AuditMsg().ClaimHealComplete(ctx, op.HealOpId, op.TicketId, manifestHash, ""); err != nil { + // Transient gRPC failures (Unavailable / DeadlineExceeded / cancellation) + // MUST NOT trigger destructive cleanup of staging — Wave 0 fix for C3. + if chainerrors.IsTransientGrpc(err) { + lep6metrics.IncHealClaim("submit_transient") + return fmt.Errorf("submit claim (transient, will retry): %w", err) + } + if chainerrors.IsHealOpPastDeadline(err) { + _ = s.store.DeletePendingHealClaim(ctx, op.HealOpId) + _ = os.RemoveAll(stagingDir) + lep6metrics.IncHealClaim("deadline_rejected") + logtrace.Warn(ctx, "self_healing(LEP-6): chain rejected heal claim after deadline; skipping reconcile", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "deadline": op.DeadlineEpochId, + logtrace.FieldError: err.Error(), + }) + return nil + } + if chainerrors.IsHealOpInvalidState(err) { + return s.reconcilePendingClaimSubmitError(ctx, op, err) + } + // Matee C3 follow-up: do not destructively drop staging on an + // unclassified submit error until we query chain state. A tx can be + // committed while the client receives a non-canonical transport / ABCI + // wrapper error that is neither IsTransientGrpc nor the typed invalid- + // state sentinel. resumePendingHealClaim promotes the row when chain + // shows our manifest, or deletes pending+staging only when chain still + // has no accepted claim / accepted a different manifest. + return s.reconcilePendingClaimSubmitError(ctx, op, err) + } + + if err := s.store.MarkHealClaimSubmitted(ctx, op.HealOpId); err != nil { + lep6metrics.IncHealClaim("mark_error") + return fmt.Errorf("mark heal claim submitted (chain accepted): %w", err) + } + lep6metrics.IncHealClaim("submitted") + logtrace.Info(ctx, "self_healing(LEP-6): claim submitted", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "ticket_id": op.TicketId, + "manifest_h": manifestHash, + "staging_dir": stagingDir, + }) + return nil +} + +func (s *Service) reconcilePendingClaimSubmitError(ctx context.Context, op audittypes.HealOp, submitErr error) error { + if recErr := s.resumePendingHealClaim(ctx, op); recErr != nil { + return fmt.Errorf("submit failed (%v) and pending reconcile failed: %w", submitErr, recErr) + } + hasSubmitted, err := s.store.HasHealClaim(ctx, op.HealOpId) + if err != nil { + return fmt.Errorf("submit failed (%v) and post-reconcile submitted lookup failed: %w", submitErr, err) + } + if hasSubmitted { + return nil + } + lep6metrics.IncHealClaim("submit_error") + return fmt.Errorf("submit claim: %w", submitErr) +} + +// reconcileExistingClaim handles the post-crash case where the chain has +// advanced past SCHEDULED (i.e. our prior submit was accepted but we lost +// the response or crashed before persisting). We re-fetch the op, confirm +// the recorded ResultHash matches the manifest we just rebuilt, and then +// persist the dedup row so the finalizer takes over. +// +// If the chain ResultHash differs, the staged data is irrelevant (a +// previous run produced different bytes — file changed underneath, or +// non-determinism slipped in). Drop staging, do nothing — let the heal-op +// run its course on chain. +func (s *Service) reconcileExistingClaim(ctx context.Context, op audittypes.HealOp, manifestHash, stagingDir string) error { + resp, err := s.lumera.Audit().GetHealOp(ctx, op.HealOpId) + if err != nil { + return fmt.Errorf("get heal op: %w", err) + } + if resp == nil { + return fmt.Errorf("nil heal op response") + } + chainOp := resp.HealOp + if chainOp.ResultHash != manifestHash { + // Different manifest on chain → our staged bytes don't match what + // chain expects. Discard staging and let the existing chain op + // finish without our involvement. + logtrace.Warn(ctx, "self_healing(LEP-6): chain ResultHash differs from current manifest; abandoning staging", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "chain_hash": chainOp.ResultHash, + "current_hash": manifestHash, + "staging_dir": stagingDir, + "chain_status": chainOp.Status.String(), + }) + _ = os.RemoveAll(stagingDir) + return nil + } + // Manifest matches — persist/mark dedup row so finalizer can publish on + // VERIFIED. If this tick pre-staged the row before seeing the already-on- + // chain error, mark it submitted; otherwise insert a submitted row. + if err := s.store.RecordHealClaim(ctx, op.HealOpId, op.TicketId, manifestHash, stagingDir); err != nil { + if errors.Is(err, queries.ErrLEP6ClaimAlreadyRecorded) { + if markErr := s.store.MarkHealClaimSubmitted(ctx, op.HealOpId); markErr != nil { + return fmt.Errorf("mark reconciled claim submitted: %w", markErr) + } + } else { + return fmt.Errorf("record reconciled claim: %w", err) + } + } + logtrace.Info(ctx, "self_healing(LEP-6): reconciled existing chain claim", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "chain_status": chainOp.Status.String(), + "manifest_h": manifestHash, + }) + lep6metrics.IncHealClaimReconciled() + lep6metrics.IncHealClaim("reconciled") + return nil +} + +// (Wave 0): isChainHealOpInvalidState helper removed; classification is now +// done via pkg/lumera/chainerrors.IsHealOpInvalidState which uses typed +// sentinel matching (audittypes.ErrHealOpInvalidState) with substring +// fallback, plus an IsTransientGrpc short-circuit at the call site to +// preserve staging on transient gRPC failures. + +// healOpDeadlinePassed reports whether op.DeadlineEpochId is at or before +// the current chain epoch. Used by healer/verifier to short-circuit +// fee-burning submits the chain would reject (H1 fix). Returns +// (false, err) if current-epoch query fails so the caller can decide +// whether to proceed (we choose to proceed-and-let-chain-reject for +// transient query failures rather than skip a still-valid op). +func (s *Service) healOpDeadlinePassed(ctx context.Context, op audittypes.HealOp) (bool, error) { + if op.DeadlineEpochId == 0 { + // Spec says 0 means "no deadline configured"; chain auto-fills + // to current+heal_deadline_epochs. If we see 0 here, don't + // pre-skip. + return false, nil + } + queryCtx, cancel := s.auditQueryContext(ctx) + defer cancel() + resp, err := s.lumera.Audit().GetCurrentEpoch(queryCtx) + if err != nil { + return false, err + } + if resp == nil || resp.EpochId == 0 { + return false, nil + } + return resp.EpochId >= op.DeadlineEpochId, nil +} + +// resumePendingHealClaim is the C5 fix: a `pending` claim row from a +// previous tick (crashed between RecordPendingHealClaim and chain ack) +// exists locally. We must reconcile against the chain BEFORE either +// resubmitting (waste) or skipping (data loss). +// +// Decision tree: +// +// - Chain advanced (HEALER_REPORTED+) and op.ResultHash matches the +// pending row's manifest_hash → our submit was actually accepted; +// promote pending → submitted; finalizer takes over. Staging dir +// is preserved (finalizer reads it). +// +// - Chain advanced (HEALER_REPORTED+) but op.ResultHash differs → some +// other healer claim was accepted; our staged bytes are irrelevant. +// Delete pending row + remove staging. +// +// - Chain still SCHEDULED → our prior submit was rejected/lost without +// acceptance. Delete pending row + remove staging so the next +// dispatch tick attempts a fresh reconstruct (chain has no record). +// +// - Chain in any final state (FAILED/EXPIRED/VERIFIED with different +// hash) → cleanup pending row + staging. +// +// Transient gRPC errors during the GetHealOp query do NOT delete state. +func (s *Service) resumePendingHealClaim(ctx context.Context, op audittypes.HealOp) error { + row, err := s.store.GetHealClaim(ctx, op.HealOpId) + if err != nil { + return fmt.Errorf("get pending claim row: %w", err) + } + if row.Status != "pending" { + // Race: another goroutine promoted/deleted the row already. + return nil + } + resp, err := s.lumera.Audit().GetHealOp(ctx, op.HealOpId) + if err != nil { + if chainerrors.IsTransientGrpc(err) { + return fmt.Errorf("get heal op (transient, will retry): %w", err) + } + // Non-transient query failure — keep pending row in place; + // next tick retries. + return fmt.Errorf("get heal op: %w", err) + } + if resp == nil || resp.HealOp.HealOpId == 0 { + return fmt.Errorf("nil/empty heal op response") + } + chainOp := resp.HealOp + switch chainOp.Status { + case audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED: + // Chain has no claim from us; our prior submit was rejected + // or lost. Drop pending row + staging; let the next tick + // re-dispatch fresh. + _ = os.RemoveAll(row.StagingDir) + if err := s.store.DeletePendingHealClaim(ctx, op.HealOpId); err != nil { + return fmt.Errorf("delete pending claim after SCHEDULED reconcile: %w", err) + } + lep6metrics.IncHealClaim("resume_reset") + logtrace.Info(ctx, "self_healing(LEP-6): resume reset (chain still SCHEDULED, dropping stale pending)", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "staging_dir": row.StagingDir, + "chain_status": chainOp.Status.String(), + }) + return nil + case audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + audittypes.HealOpStatus_HEAL_OP_STATUS_VERIFIED: + if chainOp.ResultHash == row.ManifestHash { + // Our submit was actually accepted — promote pending → submitted. + if err := s.store.MarkHealClaimSubmitted(ctx, op.HealOpId); err != nil { + return fmt.Errorf("mark heal claim submitted (resume): %w", err) + } + lep6metrics.IncHealClaimReconciled() + lep6metrics.IncHealClaim("resume_promoted") + logtrace.Info(ctx, "self_healing(LEP-6): resume promoted pending → submitted", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "chain_status": chainOp.Status.String(), + "manifest_h": row.ManifestHash, + }) + return nil + } + // Different healer's claim was accepted — drop our staging. + _ = os.RemoveAll(row.StagingDir) + if err := s.store.DeletePendingHealClaim(ctx, op.HealOpId); err != nil { + return fmt.Errorf("delete pending claim after foreign-hash reconcile: %w", err) + } + lep6metrics.IncHealClaim("resume_foreign") + logtrace.Warn(ctx, "self_healing(LEP-6): resume foreign-hash (different healer's claim accepted)", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "chain_hash": chainOp.ResultHash, + "pending_hash": row.ManifestHash, + "chain_status": chainOp.Status.String(), + "staging_dir": row.StagingDir, + }) + return nil + default: + // FAILED / EXPIRED / IN_PROGRESS / others — staging is no + // longer useful; let finalizer drain anything else. + _ = os.RemoveAll(row.StagingDir) + if err := s.store.DeletePendingHealClaim(ctx, op.HealOpId); err != nil { + return fmt.Errorf("delete pending claim after terminal reconcile: %w", err) + } + lep6metrics.IncHealClaim("resume_terminal") + return nil + } +} diff --git a/supernode/self_healing/lumera_test.go b/supernode/self_healing/lumera_test.go new file mode 100644 index 00000000..47c8284c --- /dev/null +++ b/supernode/self_healing/lumera_test.go @@ -0,0 +1,46 @@ +package self_healing + +import ( + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/action" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/action_msg" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/audit" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/audit_msg" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/auth" + bankmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/bank" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/node" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/supernode" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/supernode_msg" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/tx" + "github.com/LumeraProtocol/supernode/v2/pkg/testutil" +) + +// fakeLumera satisfies lumera.Client by composing per-test programmable +// audit modules with the existing testutil.MockLumeraClient stubs for the +// other modules. The dispatcher only touches Audit() and AuditMsg(); the +// other methods are present solely to satisfy the interface contract. +type fakeLumera struct { + audit audit.Module + auditMsg audit_msg.Module + other lumera.Client // testutil mock; supplies stub for non-audit modules +} + +func newFakeLumera(a audit.Module, am audit_msg.Module) lumera.Client { + c, err := testutil.NewMockLumeraClient(nil, nil) + if err != nil { + panic(err) + } + return &fakeLumera{audit: a, auditMsg: am, other: c} +} + +func (f *fakeLumera) Auth() auth.Module { return f.other.Auth() } +func (f *fakeLumera) Action() action.Module { return f.other.Action() } +func (f *fakeLumera) ActionMsg() action_msg.Module { return f.other.ActionMsg() } +func (f *fakeLumera) Audit() audit.Module { return f.audit } +func (f *fakeLumera) AuditMsg() audit_msg.Module { return f.auditMsg } +func (f *fakeLumera) SuperNode() supernode.Module { return f.other.SuperNode() } +func (f *fakeLumera) SuperNodeMsg() supernode_msg.Module { return f.other.SuperNodeMsg() } +func (f *fakeLumera) Bank() bankmod.Module { return f.other.Bank() } +func (f *fakeLumera) Tx() tx.Module { return f.other.Tx() } +func (f *fakeLumera) Node() node.Module { return f.other.Node() } +func (f *fakeLumera) Close() error { return nil } diff --git a/supernode/self_healing/mocks_test.go b/supernode/self_healing/mocks_test.go new file mode 100644 index 00000000..2ecaa488 --- /dev/null +++ b/supernode/self_healing/mocks_test.go @@ -0,0 +1,199 @@ +package self_healing + +import ( + "context" + "sync" + "sync/atomic" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + query "github.com/cosmos/cosmos-sdk/types/query" + sdktx "github.com/cosmos/cosmos-sdk/types/tx" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// programmableAudit is a per-test programmable audit module. The dispatcher +// reads only GetParams, GetHealOp, and GetHealOpsByStatus, so other methods +// are unused and may be left zero. +type programmableAudit struct { + mu sync.Mutex + params audittypes.Params + opsByStatus map[audittypes.HealOpStatus][]audittypes.HealOp + opsByID map[uint64]audittypes.HealOp + getOpErr error + blockStatus map[audittypes.HealOpStatus]bool + currentEpoch uint64 // wired into GetCurrentEpoch (H1 deadline-pre-check tests) + currentAnchor audittypes.EpochAnchor + epochAnchors map[uint64]audittypes.EpochAnchor +} + +func newProgrammableAudit(mode audittypes.StorageTruthEnforcementMode) *programmableAudit { + return &programmableAudit{ + params: audittypes.Params{ + StorageTruthEnforcementMode: mode, + }, + opsByStatus: map[audittypes.HealOpStatus][]audittypes.HealOp{}, + opsByID: map[uint64]audittypes.HealOp{}, + blockStatus: map[audittypes.HealOpStatus]bool{}, + epochAnchors: map[uint64]audittypes.EpochAnchor{}, + } +} + +func (p *programmableAudit) put(op audittypes.HealOp) { + p.mu.Lock() + defer p.mu.Unlock() + p.opsByID[op.HealOpId] = op + p.opsByStatus[op.Status] = append(p.opsByStatus[op.Status], op) +} + +func (p *programmableAudit) setStatus(opID uint64, st audittypes.HealOpStatus) { + p.mu.Lock() + defer p.mu.Unlock() + op := p.opsByID[opID] + op.Status = st + p.opsByID[opID] = op +} + +func (p *programmableAudit) GetParams(ctx context.Context) (*audittypes.QueryParamsResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + return &audittypes.QueryParamsResponse{Params: p.params}, nil +} +func (p *programmableAudit) GetHealOp(ctx context.Context, healOpID uint64) (*audittypes.QueryHealOpResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.getOpErr != nil { + return nil, p.getOpErr + } + op, ok := p.opsByID[healOpID] + if !ok { + // Match the production chain query surface + // (x/audit/v1/keeper/query_storage_truth.go:78): a missing heal-op + // surfaces as gRPC status.NotFound with the discriminating + // "heal op not found" message. The previous test used a bare + // errors.New("not found") which the broad legacy substring + // matcher accepted, but which Wave 0 narrowly rejects (so as not + // to swallow transient "block N not found" errors). + return nil, status.Error(codes.NotFound, "heal op not found") + } + return &audittypes.QueryHealOpResponse{HealOp: op}, nil +} +func (p *programmableAudit) GetHealOpsByStatus(ctx context.Context, status audittypes.HealOpStatus, pagination *query.PageRequest) (*audittypes.QueryHealOpsByStatusResponse, error) { + p.mu.Lock() + block := p.blockStatus[status] + p.mu.Unlock() + if block { + <-ctx.Done() + return nil, ctx.Err() + } + p.mu.Lock() + defer p.mu.Unlock() + out := make([]audittypes.HealOp, 0, len(p.opsByStatus[status])) + for _, op := range p.opsByStatus[status] { + out = append(out, op) + } + return &audittypes.QueryHealOpsByStatusResponse{HealOps: out}, nil +} +func (p *programmableAudit) GetHealOpsByTicket(ctx context.Context, ticketID string, pagination *query.PageRequest) (*audittypes.QueryHealOpsByTicketResponse, error) { + return &audittypes.QueryHealOpsByTicketResponse{}, nil +} +func (p *programmableAudit) GetEpochAnchor(ctx context.Context, epochID uint64) (*audittypes.QueryEpochAnchorResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + return &audittypes.QueryEpochAnchorResponse{Anchor: p.epochAnchors[epochID]}, nil +} +func (p *programmableAudit) GetCurrentEpochAnchor(ctx context.Context) (*audittypes.QueryCurrentEpochAnchorResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + return &audittypes.QueryCurrentEpochAnchorResponse{Anchor: p.currentAnchor}, nil +} +func (p *programmableAudit) GetCurrentEpoch(ctx context.Context) (*audittypes.QueryCurrentEpochResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + return &audittypes.QueryCurrentEpochResponse{EpochId: p.currentEpoch}, nil +} +func (p *programmableAudit) GetAssignedTargets(ctx context.Context, supernodeAccount string, epochID uint64) (*audittypes.QueryAssignedTargetsResponse, error) { + return &audittypes.QueryAssignedTargetsResponse{}, nil +} +func (p *programmableAudit) GetEpochReport(ctx context.Context, epochID uint64, supernodeAccount string) (*audittypes.QueryEpochReportResponse, error) { + return &audittypes.QueryEpochReportResponse{}, nil +} +func (p *programmableAudit) GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*audittypes.QueryEpochReportsByReporterResponse, error) { + return &audittypes.QueryEpochReportsByReporterResponse{}, nil +} +func (p *programmableAudit) GetNodeSuspicionState(ctx context.Context, supernodeAccount string) (*audittypes.QueryNodeSuspicionStateResponse, error) { + return &audittypes.QueryNodeSuspicionStateResponse{}, nil +} +func (p *programmableAudit) GetReporterReliabilityState(ctx context.Context, reporterAccount string) (*audittypes.QueryReporterReliabilityStateResponse, error) { + return &audittypes.QueryReporterReliabilityStateResponse{}, nil +} +func (p *programmableAudit) GetTicketDeteriorationState(ctx context.Context, ticketID string) (*audittypes.QueryTicketDeteriorationStateResponse, error) { + return &audittypes.QueryTicketDeteriorationStateResponse{}, nil +} + +// programmableAuditMsg captures every claim/verification call so tests can +// assert on the exact arguments the dispatcher used (e.g. that +// VerificationHash matches op.ResultHash and never Action.DataHash). +type programmableAuditMsg struct { + mu sync.Mutex + claimCalls []claimCall + verificationCalls []verificationCall + claimErr error + verificationErr error + claimsCount atomic.Int64 + verificationsCount atomic.Int64 +} + +type claimCall struct { + HealOpID uint64 + TicketID string + HealManifestHash string + Details string +} + +type verificationCall struct { + HealOpID uint64 + Verified bool + VerificationHash string + Details string +} + +func newProgrammableAuditMsg() *programmableAuditMsg { return &programmableAuditMsg{} } + +func (p *programmableAuditMsg) ClaimHealComplete(ctx context.Context, healOpID uint64, ticketID, healManifestHash, details string) (*sdktx.BroadcastTxResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.claimErr != nil { + return nil, p.claimErr + } + p.claimCalls = append(p.claimCalls, claimCall{healOpID, ticketID, healManifestHash, details}) + p.claimsCount.Add(1) + return &sdktx.BroadcastTxResponse{}, nil +} +func (p *programmableAuditMsg) SubmitHealVerification(ctx context.Context, healOpID uint64, verified bool, verificationHash, details string) (*sdktx.BroadcastTxResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.verificationErr != nil { + return nil, p.verificationErr + } + p.verificationCalls = append(p.verificationCalls, verificationCall{healOpID, verified, verificationHash, details}) + p.verificationsCount.Add(1) + return &sdktx.BroadcastTxResponse{}, nil +} +func (p *programmableAuditMsg) SubmitEvidence(ctx context.Context, subjectAddress string, evidenceType audittypes.EvidenceType, actionID string, metadataJSON string) (*sdktx.BroadcastTxResponse, error) { + return &sdktx.BroadcastTxResponse{}, nil +} +func (p *programmableAuditMsg) SubmitEpochReport(ctx context.Context, epochID uint64, hostReport audittypes.HostReport, storageChallengeObservations []*audittypes.StorageChallengeObservation, storageProofResults []*audittypes.StorageProofResult) (*sdktx.BroadcastTxResponse, error) { + return &sdktx.BroadcastTxResponse{}, nil +} +func (p *programmableAuditMsg) SubmitStorageRecheckEvidence(ctx context.Context, epochID uint64, challengedSupernodeAccount string, ticketID string, challengedResultTranscriptHash string, recheckTranscriptHash string, recheckResultClass audittypes.StorageProofResultClass, details string) (*sdktx.BroadcastTxResponse, error) { + return &sdktx.BroadcastTxResponse{}, nil +} + +func (p *programmableAuditMsg) snapshot() ([]claimCall, []verificationCall) { + p.mu.Lock() + defer p.mu.Unlock() + c := append([]claimCall(nil), p.claimCalls...) + v := append([]verificationCall(nil), p.verificationCalls...) + return c, v +} diff --git a/supernode/self_healing/peer_client.go b/supernode/self_healing/peer_client.go new file mode 100644 index 00000000..37cef2c8 --- /dev/null +++ b/supernode/self_healing/peer_client.go @@ -0,0 +1,161 @@ +package self_healing + +import ( + "context" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + + "github.com/LumeraProtocol/lumera/x/lumeraid/securekeyx" + "github.com/LumeraProtocol/supernode/v2/gen/supernode" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + "github.com/LumeraProtocol/supernode/v2/pkg/net/credentials" + grpcclient "github.com/LumeraProtocol/supernode/v2/pkg/net/grpc/client" + "github.com/LumeraProtocol/supernode/v2/pkg/netutil" + "github.com/cosmos/cosmos-sdk/crypto/keyring" +) + +// secureVerifierFetcher implements VerifierFetcher by dialing the assigned +// healer over the same secure-rpc / lumeraid stack the legacy +// storage_challenge loop uses. +type secureVerifierFetcher struct { + lumera lumera.Client + kr keyring.Keyring + self string + defaultPort uint16 + + mu sync.Mutex + grpcClient *grpcclient.Client + grpcOpts *grpcclient.ClientOptions +} + +// NewSecureVerifierFetcher constructs the production-grade VerifierFetcher +// for the LEP-6 §19 healer-served path. self is the local supernode +// identity; defaultPort is the supernode gRPC port to fall back to when the +// chain-registered address omits a port. +func NewSecureVerifierFetcher(client lumera.Client, kr keyring.Keyring, self string, defaultPort uint16) VerifierFetcher { + return &secureVerifierFetcher{ + lumera: client, + kr: kr, + self: strings.TrimSpace(self), + defaultPort: defaultPort, + } +} + +func (f *secureVerifierFetcher) ensureClient() error { + f.mu.Lock() + defer f.mu.Unlock() + + if f.grpcClient != nil { + return nil + } + validator := lumera.NewSecureKeyExchangeValidator(f.lumera) + creds, err := credentials.NewClientCreds(&credentials.ClientOptions{ + CommonOptions: credentials.CommonOptions{ + Keyring: f.kr, + LocalIdentity: f.self, + PeerType: securekeyx.Supernode, + Validator: validator, + }, + }) + if err != nil { + return fmt.Errorf("create secure gRPC client creds: %w", err) + } + f.grpcClient = grpcclient.NewClient(creds) + f.grpcOpts = grpcclient.DefaultClientOptions() + f.grpcOpts.EnableRetries = false // verifier orchestrates retries itself + return nil +} + +// FetchReconstructed dials healerAccount and streams the reconstructed +// bytes for healOpID, returning the concatenated payload. +func (f *secureVerifierFetcher) FetchReconstructed(ctx context.Context, healOpID uint64, healerAccount, verifierAccount string) ([]byte, error) { + if err := f.ensureClient(); err != nil { + return nil, err + } + info, err := f.lumera.SuperNode().GetSupernodeWithLatestAddress(ctx, healerAccount) + if err != nil || info == nil { + return nil, fmt.Errorf("resolve healer %q: %w", healerAccount, err) + } + raw := strings.TrimSpace(info.LatestAddress) + if raw == "" { + return nil, fmt.Errorf("no address for healer %q", healerAccount) + } + host, port, ok := netutil.ParseHostAndPort(raw, int(f.defaultPort)) + if !ok || strings.TrimSpace(host) == "" { + return nil, fmt.Errorf("invalid healer address %q", raw) + } + addr := net.JoinHostPort(strings.TrimSpace(host), strconv.Itoa(port)) + conn, err := f.grpcClient.Connect(ctx, fmt.Sprintf("%s@%s", strings.TrimSpace(healerAccount), addr), f.grpcOpts) + if err != nil { + return nil, fmt.Errorf("dial healer %q: %w", healerAccount, err) + } + defer conn.Close() + client := supernode.NewSelfHealingServiceClient(conn) + stream, err := client.ServeReconstructedArtefacts(ctx, &supernode.ServeReconstructedArtefactsRequest{ + HealOpId: healOpID, + VerifierAccount: verifierAccount, + }) + if err != nil { + return nil, fmt.Errorf("open serve stream: %w", err) + } + // H7 fix: bound the verifier-side accumulator so a buggy or + // malicious healer cannot OOM the verifier by streaming more than + // MaxReconstructedBytes (or more than its own advertised TotalSize). + // TotalSize is read from the first message and validated against the + // supernode-wide ceiling before any allocation. + var ( + buf []byte + totalSize uint64 // 0 = not yet advertised + seenFirst bool + ) + for { + msg, err := stream.Recv() + if err == io.EOF { + return buf, nil + } + if err != nil { + return nil, fmt.Errorf("recv: %w", err) + } + if !seenFirst { + seenFirst = true + totalSize = msg.TotalSize + if totalSize > MaxReconstructedBytes { + return nil, fmt.Errorf("healer advertised total_size=%d exceeds MaxReconstructedBytes=%d", totalSize, MaxReconstructedBytes) + } + if totalSize > 0 { + buf = make([]byte, 0, totalSize) + } + } + // Per-chunk overflow check — works for both bounded (TotalSize>0) + // and legacy unbounded (TotalSize=0) streams; in the unbounded + // case we still cap at MaxReconstructedBytes so a stream that + // "forgets" to advertise size is still safe. + next := uint64(len(buf)) + uint64(len(msg.Chunk)) + if totalSize > 0 && next > totalSize { + return nil, fmt.Errorf("healer streamed %d bytes, exceeds advertised total_size=%d", next, totalSize) + } + if next > MaxReconstructedBytes { + return nil, fmt.Errorf("healer streamed %d bytes, exceeds MaxReconstructedBytes=%d", next, MaxReconstructedBytes) + } + buf = append(buf, msg.Chunk...) + if msg.IsLast { + // Drain any trailer. + _, _ = stream.Recv() + if totalSize > 0 && uint64(len(buf)) != totalSize { + return nil, fmt.Errorf("healer reached IsLast at %d bytes; advertised total_size=%d", len(buf), totalSize) + } + return buf, nil + } + } +} + +// MaxReconstructedBytes caps the verifier-side accumulator for the §19 +// healer-served path. Set to 4 GiB which matches typical cascade max-action +// size and bounds the worst-case verifier RAM footprint at runtime. The +// chain-side action enforcement is the authoritative check; this is a +// supernode-side defense-in-depth (H7). +const MaxReconstructedBytes uint64 = 4 * 1024 * 1024 * 1024 diff --git a/supernode/self_healing/service.go b/supernode/self_healing/service.go new file mode 100644 index 00000000..394c1c44 --- /dev/null +++ b/supernode/self_healing/service.go @@ -0,0 +1,740 @@ +// Package self_healing implements the LEP-6 chain-driven heal-op runtime. +// +// # Architecture +// +// LEP-6 §18-§22 (Workstream C) replaces the gonode-era peer-watchlist self- +// healing with a chain-mediated three-phase flow. The chain (lumera/x/audit) +// owns role assignment via HealOp.HealerSupernodeAccount + .VerifierSupernode +// Accounts, and quorum via MsgClaimHealComplete + MsgSubmitHealVerification +// (n/2+1 positive verifications). The supernode side is purely an executor: +// +// Phase 1 — RECONSTRUCT (no publish) +// Healer fetches symbols from KAD, RaptorQ-decodes, verifies hash against +// Action.DataHash, re-encodes, STAGES to local disk, then submits +// MsgClaimHealComplete{HealManifestHash}. The reconstructed file MUST NOT +// enter KAD before chain VERIFIED — §19 healer-served path. +// +// Phase 2 — VERIFY +// Each verifier fetches the reconstructed bytes from the assigned healer +// via supernode.SelfHealingService/ServeReconstructedArtefacts, hashes +// them with cascadekit.ComputeBlake3DataHashB64 (= Action.DataHash recipe), +// compares against op.ResultHash (NOT Action.DataHash — chain-side +// enforcement at lumera/x/audit/v1/keeper/msg_storage_truth.go:291), and +// submits MsgSubmitHealVerification{verified, hash}. The "compare against +// op.ResultHash" choice is the v3-plan landmine pinned by +// TestVerifier_ComparesAgainstOpResultHash. +// +// Phase 3 — PUBLISH (only on VERIFIED) +// Healer's finalizer polls staging entries, calls +// cascadeService.PublishStagedArtefacts on op.Status == VERIFIED, then +// deletes the staging dir. On FAILED / EXPIRED, the staging dir is +// deleted with no publish — chain may reschedule with a different healer. +// +// # Concurrency +// +// Three-layer dedup so a process restart can never double-submit: +// 1. sync.Map keyed on (heal_op_id, role) for in-flight locking. +// 2. Buffered semaphore (default 2) capping concurrent RaptorQ reseeds — +// reseed is RAM-heavy. Verification semaphore default 4, publish 2. +// 3. SQLite tables heal_claims_submitted + heal_verifications_submitted +// (pkg/storage/queries/self_healing_lep6.go) for restart dedup. +// +// # Mode gate +// +// When params.StorageTruthEnforcementMode == UNSPECIFIED the chain creates +// no heal-ops, so the dispatcher early-returns from Service.tick. The check +// also serves as a final supernode-side guard. +package self_healing + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" + "github.com/LumeraProtocol/supernode/v2/pkg/storage/queries" + cascadeService "github.com/LumeraProtocol/supernode/v2/supernode/cascade" + query "github.com/cosmos/cosmos-sdk/types/query" + "golang.org/x/sync/semaphore" +) + +// Defaults captured here for clarity at the boundary; Config exposes overrides. +const ( + defaultPollInterval = 30 * time.Second + defaultMaxConcurrentReconstructs = 2 + defaultMaxConcurrentVerifications = 4 + defaultMaxConcurrentPublishes = 2 + defaultStagingRoot = "heal-staging" + defaultVerifierFetchTimeout = 60 * time.Second + defaultVerifierFetchAttempts = 3 + defaultVerifierBackoffBase = 2 * time.Second + defaultAuditQueryTimeout = 10 * time.Second + // defaultDispatchOpTimeout caps the wall time any single per-op + // dispatcher goroutine (healer/verifier/publisher) may run before its + // derived ctx cancels. Wave 2 / M2 fix — prevents a wedged peer fetch + // or hung RaptorQ from holding its semaphore slot + inFlight key + // forever. + defaultDispatchOpTimeout = 15 * time.Minute + // defaultEstimatedChainBlockTime is used only to translate chain epoch-anchor + // block deltas into a wall-clock deadline for per-op contexts when the chain + // exposes heights but not timestamps. The hard DispatchOpTimeout remains the + // upper safety cap. + defaultEstimatedChainBlockTime = 6 * time.Second +) + +// Config captures supernode-binary-owned tunables for the LEP-6 heal runtime. +type Config struct { + // Enabled toggles the entire dispatcher. Independent of the chain mode + // gate; if Enabled=false the service never runs even when chain mode is + // FULL. Used for staged rollouts. + Enabled bool + PollInterval time.Duration + MaxConcurrentReconstructs int + MaxConcurrentVerifications int + MaxConcurrentPublishes int + + // StagingRoot is the local directory under which per-heal-op staging + // dirs are created. Default: ~/.supernode/heal-staging/. + StagingRoot string + + // VerifierFetchTimeout / VerifierFetchAttempts / VerifierBackoffBase + // shape the retry policy verifiers use when fetching from the assigned + // healer. After exhausting attempts, verifier submits verified=false + // with reason "fetch_failed". + VerifierFetchTimeout time.Duration + VerifierFetchAttempts int + VerifierBackoffBase time.Duration + + // AuditQueryTimeout bounds each chain query made by the dispatcher. A + // wedged status/params query must not pin the whole tick forever and starve + // other roles (especially verifier dispatch while a healer-reported op is + // waiting on quorum before deadline). + AuditQueryTimeout time.Duration + + // DispatchOpTimeout is a hard ceiling on each per-op goroutine + // (healer reconstruct+claim, verifier fetch+submit, publisher). + // Wave 2 / M2 fix — prevents semaphore-slot/inFlight-key leak on a + // wedged peer fetch or hung RaptorQ. 0 → defaultDispatchOpTimeout. + DispatchOpTimeout time.Duration + + // KeyName is the supernode's keyring key used to sign claim/verification + // txs. Must match the on-chain HealerSupernodeAccount / + // VerifierSupernodeAccount. + KeyName string +} + +func (c Config) withDefaults() Config { + if c.PollInterval <= 0 { + c.PollInterval = defaultPollInterval + } + if c.MaxConcurrentReconstructs <= 0 { + c.MaxConcurrentReconstructs = defaultMaxConcurrentReconstructs + } + if c.MaxConcurrentVerifications <= 0 { + c.MaxConcurrentVerifications = defaultMaxConcurrentVerifications + } + if c.MaxConcurrentPublishes <= 0 { + c.MaxConcurrentPublishes = defaultMaxConcurrentPublishes + } + if strings.TrimSpace(c.StagingRoot) == "" { + home, err := os.UserHomeDir() + if err == nil { + c.StagingRoot = filepath.Join(home, ".supernode", defaultStagingRoot) + } else { + c.StagingRoot = filepath.Join(os.TempDir(), defaultStagingRoot) + } + } + if c.VerifierFetchTimeout <= 0 { + c.VerifierFetchTimeout = defaultVerifierFetchTimeout + } + if c.VerifierFetchAttempts <= 0 { + c.VerifierFetchAttempts = defaultVerifierFetchAttempts + } + if c.VerifierBackoffBase <= 0 { + c.VerifierBackoffBase = defaultVerifierBackoffBase + } + if c.AuditQueryTimeout <= 0 { + c.AuditQueryTimeout = defaultAuditQueryTimeout + } + if c.DispatchOpTimeout <= 0 { + c.DispatchOpTimeout = defaultDispatchOpTimeout + } + return c +} + +// VerifierFetcher abstracts the verifier→healer transport. Real +// implementation is grpc-based (peer_client.go); tests inject in-memory +// fakes that don't need a listening server. +type VerifierFetcher interface { + // FetchReconstructed retrieves the reconstructed file bytes from the + // healer assigned to healOpID. Implementations are responsible for + // dialing the healer's grpc endpoint (resolved from the supernode + // registry) and authenticating as verifierAccount. + FetchReconstructed(ctx context.Context, healOpID uint64, healerAccount, verifierAccount string) ([]byte, error) +} + +// Service is the single LEP-6 heal-op dispatcher. One instance per +// supernode binary. +type Service struct { + cfg Config + identity string + + lumera lumera.Client + store queries.LocalStoreInterface + cascadeFactory cascadeService.CascadeServiceFactory + fetcher VerifierFetcher + + // In-flight dedup. Key: opRoleKey(healOpID, role). Value: struct{}. + inFlight sync.Map + + // Per-role concurrency caps. + semReconstruct *semaphore.Weighted + semVerify *semaphore.Weighted + semPublish *semaphore.Weighted +} + +const ( + roleHealer = "healer" + roleVerifier = "verifier" + rolePublisher = "publisher" +) + +func opRoleKey(healOpID uint64, role string) string { + return fmt.Sprintf("%d/%s", healOpID, role) +} + +// New constructs a Service. fetcher may be nil if Config.Enabled is false +// (constructor still validates required deps so misconfig is caught early). +func New( + identity string, + cfg Config, + lumeraClient lumera.Client, + store queries.LocalStoreInterface, + cascadeFactory cascadeService.CascadeServiceFactory, + fetcher VerifierFetcher, +) (*Service, error) { + identity = strings.TrimSpace(identity) + if identity == "" { + return nil, fmt.Errorf("identity is empty") + } + if lumeraClient == nil || lumeraClient.Audit() == nil || lumeraClient.AuditMsg() == nil { + return nil, fmt.Errorf("lumera client missing required audit modules") + } + if store == nil { + return nil, fmt.Errorf("local store is nil") + } + if cascadeFactory == nil { + return nil, fmt.Errorf("cascade service factory is nil") + } + cfg = cfg.withDefaults() + if err := os.MkdirAll(cfg.StagingRoot, 0o700); err != nil { + return nil, fmt.Errorf("create staging root %q: %w", cfg.StagingRoot, err) + } + return &Service{ + cfg: cfg, + identity: identity, + lumera: lumeraClient, + store: store, + cascadeFactory: cascadeFactory, + fetcher: fetcher, + semReconstruct: semaphore.NewWeighted(int64(cfg.MaxConcurrentReconstructs)), + semVerify: semaphore.NewWeighted(int64(cfg.MaxConcurrentVerifications)), + semPublish: semaphore.NewWeighted(int64(cfg.MaxConcurrentPublishes)), + }, nil +} + +func (s *Service) cleanupOrphanedStagingDirs(ctx context.Context) error { + claims, err := s.store.ListHealClaims(ctx) + if err != nil { + return fmt.Errorf("list heal claims: %w", err) + } + known := make(map[uint64]struct{}, len(claims)) + for _, claim := range claims { + known[claim.HealOpID] = struct{}{} + } + entries, err := os.ReadDir(s.cfg.StagingRoot) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read staging root: %w", err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + healOpID, ok := parseNumericHealOpDir(entry.Name()) + if !ok { + continue + } + if _, exists := known[healOpID]; exists { + continue + } + path := filepath.Join(s.cfg.StagingRoot, entry.Name()) + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("remove orphaned staging dir %q: %w", path, err) + } + lep6metrics.IncHealOrphanedStagingCleanup() + logtrace.Warn(ctx, "self_healing(LEP-6): removed orphaned staging dir", logtrace.Fields{ + "heal_op_id": healOpID, + "staging_dir": path, + "staging_root": s.cfg.StagingRoot, + }) + } + return nil +} + +func parseNumericHealOpDir(name string) (uint64, bool) { + if name == "" { + return 0, false + } + for _, r := range name { + if r < '0' || r > '9' { + return 0, false + } + } + id, err := strconv.ParseUint(name, 10, 64) + if err != nil { + return 0, false + } + return id, true +} + +// Run blocks until ctx is cancelled, ticking every cfg.PollInterval. +// Tick steps (single mechanism per LEP-6 plan §C.4 finalizer Opt-2b decision): +// +// 1. Mode gate: query audit params; if UNSPECIFIED, skip everything. +// 2. Healer dispatch: GetHealOpsByStatus(SCHEDULED), filter by +// HealerSupernodeAccount==identity, run reconstructHealOp() bounded by +// semReconstruct. +// 3. Verifier dispatch: GetHealOpsByStatus(HEALER_REPORTED), filter by +// identity ∈ VerifierSupernodeAccounts, run verifyHealOp() bounded by +// semVerify. +// 4. Finalizer (Opt 2b per-op poll): for each row in heal_claims_submitted, +// GetHealOp(opID) and act on Status (VERIFIED → publish, FAILED/EXPIRED +// → cleanup). +// +// Final-state ops are excluded by status filter, so a misordered tick is +// idempotent (sync.Map dedup + sqlite dedup catch any race). +func (s *Service) Run(ctx context.Context) error { + if !s.cfg.Enabled { + logtrace.Info(ctx, "self_healing(LEP-6): disabled in config; not starting", logtrace.Fields{}) + return nil + } + logtrace.Info(ctx, "self_healing(LEP-6): start", logtrace.Fields{ + "identity": s.identity, + "poll_interval": s.cfg.PollInterval.String(), + "max_concurrent_reconstructs": s.cfg.MaxConcurrentReconstructs, + "max_concurrent_verifications": s.cfg.MaxConcurrentVerifications, + "max_concurrent_publishes": s.cfg.MaxConcurrentPublishes, + "staging_root": s.cfg.StagingRoot, + }) + if err := s.cleanupOrphanedStagingDirs(ctx); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): cleanup orphaned staging dirs", logtrace.Fields{logtrace.FieldError: err.Error()}) + } + t := time.NewTicker(s.cfg.PollInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-t.C: + if err := s.tick(ctx); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): tick error", logtrace.Fields{logtrace.FieldError: err.Error()}) + } + } + } +} + +// tick performs one dispatch cycle. Exposed for tests. +func (s *Service) tick(ctx context.Context) error { + skip, err := s.modeGate(ctx) + if err != nil { + return fmt.Errorf("mode gate: %w", err) + } + if !skip { + // Mode-gated phases — chain creates no fresh heal-ops in + // UNSPECIFIED, so dispatching healer/verifier work would be + // a no-op anyway. + if err := s.dispatchHealerOps(ctx); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): dispatch healer ops", logtrace.Fields{logtrace.FieldError: err.Error()}) + } + if err := s.dispatchVerifierOps(ctx); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): dispatch verifier ops", logtrace.Fields{logtrace.FieldError: err.Error()}) + } + } + // M7 fix: finalizer runs ALWAYS, even when modeGate says skip. + // Pre-existing pending claim rows + staging dirs from a prior run + // (or from a governance rollback to UNSPECIFIED while in-flight ops + // were live) must still be drained — otherwise staging dirs leak + // forever after rollback. Finalizer is read-mostly w.r.t. chain + // (GetHealOp queries) so it doesn't generate work in UNSPECIFIED. + if err := s.dispatchFinalizer(ctx); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): dispatch finalizer", logtrace.Fields{logtrace.FieldError: err.Error()}) + } + return nil +} + +// modeGate returns (skip=true) when the chain enforcement mode is +// UNSPECIFIED. Heal-ops only exist in SHADOW/SOFT/FULL. +func (s *Service) modeGate(ctx context.Context) (bool, error) { + queryCtx, cancel := s.auditQueryContext(ctx) + defer cancel() + resp, err := s.lumera.Audit().GetParams(queryCtx) + if err != nil { + return false, err + } + mode := resp.Params.StorageTruthEnforcementMode + if mode == audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED { + return true, nil + } + return false, nil +} + +// dispatchHealerOps: pulls SCHEDULED ops where I'm the assigned healer and +// kicks off reconstruction via the healer goroutine pool. +func (s *Service) dispatchHealerOps(ctx context.Context) error { + ops, err := s.listOps(ctx, audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED) + if err != nil { + return err + } + for i := range ops { + op := ops[i] + if op.HealerSupernodeAccount != s.identity { + continue + } + if isFinalStatus(op.Status) { + continue + } + key := opRoleKey(op.HealOpId, roleHealer) + if _, loaded := s.inFlight.LoadOrStore(key, struct{}{}); loaded { + continue + } + // Restart-time dedup: if a row already exists in heal_claims_submitted + // the chain has accepted the claim — switch to publisher / leave to + // finalizer. + has, err := s.store.HasHealClaim(ctx, op.HealOpId) + if err != nil { + s.inFlight.Delete(key) + logtrace.Warn(ctx, "self_healing(LEP-6): HasHealClaim", logtrace.Fields{logtrace.FieldError: err.Error(), "heal_op_id": op.HealOpId}) + continue + } + if has { + s.inFlight.Delete(key) + continue + } + // C5 fix: if a pending row exists from an interrupted previous + // tick (crash between RecordPendingHealClaim and chain ack), + // HasHealClaim above returns false (submitted-only). Run the + // resume-reconcile path BEFORE attempting fresh reconstruct so + // we don't waste RaptorQ + bandwidth re-rebuilding bytes the + // chain may have already accepted. + hasPending, perr := s.store.HasPendingHealClaim(ctx, op.HealOpId) + if perr != nil { + s.inFlight.Delete(key) + logtrace.Warn(ctx, "self_healing(LEP-6): HasPendingHealClaim", logtrace.Fields{logtrace.FieldError: perr.Error(), "heal_op_id": op.HealOpId}) + continue + } + if hasPending { + go func(op audittypes.HealOp, key string) { + defer s.inFlight.Delete(key) + if err := s.resumePendingHealClaim(ctx, op); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): resumePendingHealClaim", logtrace.Fields{ + logtrace.FieldError: err.Error(), + "heal_op_id": op.HealOpId, + }) + } + }(op, key) + continue + } + go func(op audittypes.HealOp, key string) { + defer s.inFlight.Delete(key) + // M2 fix: bound the per-op goroutine so a wedged + // reconstruct or hung RaptorQ releases its semaphore slot. + opCtx, cancel := s.dispatchOpContextForHealOp(ctx, op) + defer cancel() + if err := s.reconstructAndClaim(opCtx, op); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): reconstructAndClaim", logtrace.Fields{ + logtrace.FieldError: err.Error(), + "heal_op_id": op.HealOpId, + "ticket_id": op.TicketId, + }) + } + }(op, key) + } + return nil +} + +// dispatchVerifierOps: pulls HEALER_REPORTED ops where I'm an assigned +// verifier and kicks off verification. +func (s *Service) dispatchVerifierOps(ctx context.Context) error { + ops, err := s.listOps(ctx, audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED) + if err != nil { + return err + } + if len(ops) > 0 { + logtrace.Info(ctx, "self_healing(LEP-6): verifier status scan", logtrace.Fields{ + "identity": s.identity, + "ops": len(ops), + }) + } + for i := range ops { + op := ops[i] + if !accountInList(s.identity, op.VerifierSupernodeAccounts) { + logtrace.Debug(ctx, "self_healing(LEP-6): verifier op not assigned locally", logtrace.Fields{ + "identity": s.identity, + "heal_op_id": op.HealOpId, + }) + continue + } + if isFinalStatus(op.Status) { + continue + } + key := opRoleKey(op.HealOpId, roleVerifier) + if _, loaded := s.inFlight.LoadOrStore(key, struct{}{}); loaded { + continue + } + has, err := s.store.HasHealVerification(ctx, op.HealOpId, s.identity) + if err != nil { + s.inFlight.Delete(key) + logtrace.Warn(ctx, "self_healing(LEP-6): HasHealVerification", logtrace.Fields{logtrace.FieldError: err.Error(), "heal_op_id": op.HealOpId}) + continue + } + if has { + s.inFlight.Delete(key) + continue + } + // C5 fix: detect a stranded `pending` verifier row from a crash + // mid-submit. The next tick must retry; we cannot just leave the + // row stuck or quorum may fail. + hasPending, perr := s.store.HasPendingHealVerification(ctx, op.HealOpId, s.identity) + if perr != nil { + s.inFlight.Delete(key) + logtrace.Warn(ctx, "self_healing(LEP-6): HasPendingHealVerification", logtrace.Fields{logtrace.FieldError: perr.Error(), "heal_op_id": op.HealOpId}) + continue + } + if hasPending { + // Best-effort: drop the stale pending row so the next + // re-attempt below proceeds normally. Chain-side dedup + // (ErrHealVerificationExists) absorbs any duplicate that + // did get through. + if delErr := s.store.DeletePendingHealVerification(ctx, op.HealOpId, s.identity); delErr != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): drop stale pending verification", logtrace.Fields{logtrace.FieldError: delErr.Error(), "heal_op_id": op.HealOpId}) + } + } + go func(op audittypes.HealOp, key string) { + defer s.inFlight.Delete(key) + // M2 fix: bound per-op verifier goroutine. + opCtx, cancel := s.dispatchOpContextForHealOp(ctx, op) + defer cancel() + logtrace.Info(opCtx, "self_healing(LEP-6): verifier dispatch start", logtrace.Fields{ + "identity": s.identity, + "heal_op_id": op.HealOpId, + "ticket_id": op.TicketId, + }) + if err := s.verifyAndSubmit(opCtx, op); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): verifyAndSubmit", logtrace.Fields{ + logtrace.FieldError: err.Error(), + "heal_op_id": op.HealOpId, + }) + } + logtrace.Info(ctx, "self_healing(LEP-6): verifier dispatch end", logtrace.Fields{ + "identity": s.identity, + "heal_op_id": op.HealOpId, + }) + }(op, key) + } + return nil +} + +// dispatchFinalizer: for each persisted heal_claims_submitted row, look up +// the on-chain status and either publish (VERIFIED) or cleanup +// (FAILED/EXPIRED). SCHEDULED / HEALER_REPORTED / IN_PROGRESS are no-ops. +func (s *Service) dispatchFinalizer(ctx context.Context) error { + claims, err := s.store.ListHealClaims(ctx) + if err != nil { + return err + } + lep6metrics.SetSelfHealingPendingClaims(len(claims)) + lep6metrics.SetSelfHealingStagingBytes(totalStagingBytes(claims)) + for _, claim := range claims { + key := opRoleKey(claim.HealOpID, rolePublisher) + if _, loaded := s.inFlight.LoadOrStore(key, struct{}{}); loaded { + continue + } + go func(claim queries.HealClaimRecord, key string) { + defer s.inFlight.Delete(key) + // M2 fix: bound per-op finalizer goroutine. + opCtx, cancel := s.dispatchOpContext(ctx) + defer cancel() + if err := s.finalizeClaim(opCtx, claim); err != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): finalizeClaim", logtrace.Fields{ + logtrace.FieldError: err.Error(), + "heal_op_id": claim.HealOpID, + }) + } + }(claim, key) + } + return nil +} + +// listOps wraps the paginated audit query. Walks pagination.NextKey until +// exhausted (H2 fix — previous nil-pagination call dropped any heal-op past +// the SDK default page size of 100, silently). A hard ceiling of +// maxHealOpListPages prevents runaway loops if a buggy chain build never +// returns an empty NextKey. +func (s *Service) listOps(ctx context.Context, status audittypes.HealOpStatus) ([]audittypes.HealOp, error) { + const maxHealOpListPages = 100 + const pageLimit uint64 = 100 + var ( + all []audittypes.HealOp + pageKey []byte + ) + for page := 0; page < maxHealOpListPages; page++ { + queryCtx, cancel := s.auditQueryContext(ctx) + resp, err := s.lumera.Audit().GetHealOpsByStatus(queryCtx, status, &query.PageRequest{Key: pageKey, Limit: pageLimit}) + cancel() + if err != nil { + return nil, err + } + if resp == nil { + return all, nil + } + all = append(all, resp.HealOps...) + if resp.Pagination == nil || len(resp.Pagination.NextKey) == 0 { + return all, nil + } + pageKey = resp.Pagination.NextKey + } + logtrace.Warn(ctx, "self_healing(LEP-6): listOps hit max-pages ceiling; results truncated", logtrace.Fields{ + "status": status.String(), + "max_pages": maxHealOpListPages, + "collected": len(all), + }) + return all, nil +} + +func (s *Service) auditQueryContext(ctx context.Context) (context.Context, context.CancelFunc) { + timeout := s.cfg.AuditQueryTimeout + if timeout <= 0 { + timeout = defaultAuditQueryTimeout + } + return context.WithTimeout(ctx, timeout) +} + +// dispatchOpContext derives a per-op-goroutine ctx with the configured +// hard ceiling so a wedged reconstruct/fetch/publish releases its +// semaphore slot + inFlight key. Wave 2 / M2 fix. +func (s *Service) dispatchOpContext(ctx context.Context) (context.Context, context.CancelFunc) { + timeout := s.cfg.DispatchOpTimeout + if timeout <= 0 { + timeout = defaultDispatchOpTimeout + } + return context.WithTimeout(ctx, timeout) +} + +// dispatchOpContextForHealOp derives the per-op context from the earlier of: +// +// - the configured hard DispatchOpTimeout; and +// - op.DeadlineEpochId translated through chain epoch-anchor heights. +// +// Lumera chain epoch anchors carry block heights (not timestamps), so the +// translation uses a conservative estimated block time and still falls back to +// DispatchOpTimeout if anchor queries fail or produce unusable data. This keeps +// the M2 leak guard intact while respecting the chain heal-op deadline when it +// can be derived locally. +func (s *Service) dispatchOpContextForHealOp(ctx context.Context, op audittypes.HealOp) (context.Context, context.CancelFunc) { + deadline, ok := s.healOpWallDeadline(ctx, op) + if !ok { + return s.dispatchOpContext(ctx) + } + hardTimeout := s.cfg.DispatchOpTimeout + if hardTimeout <= 0 { + hardTimeout = defaultDispatchOpTimeout + } + hardDeadline := time.Now().Add(hardTimeout) + if deadline.After(hardDeadline) { + deadline = hardDeadline + } + return context.WithDeadline(ctx, deadline) +} + +func (s *Service) healOpWallDeadline(ctx context.Context, op audittypes.HealOp) (time.Time, bool) { + if op.DeadlineEpochId == 0 { + return time.Time{}, false + } + queryCtx, cancel := s.auditQueryContext(ctx) + currentResp, err := s.lumera.Audit().GetCurrentEpochAnchor(queryCtx) + cancel() + if err != nil || currentResp == nil { + return time.Time{}, false + } + current := currentResp.Anchor + if current.EpochId == 0 || current.EpochEndHeight <= 0 { + return time.Time{}, false + } + if current.EpochId >= op.DeadlineEpochId { + return time.Now(), true + } + queryCtx, cancel = s.auditQueryContext(ctx) + deadlineResp, err := s.lumera.Audit().GetEpochAnchor(queryCtx, op.DeadlineEpochId) + cancel() + if err != nil || deadlineResp == nil { + return time.Time{}, false + } + deadlineAnchor := deadlineResp.Anchor + if deadlineAnchor.EpochEndHeight <= current.EpochEndHeight { + return time.Now(), true + } + remainingBlocks := deadlineAnchor.EpochEndHeight - current.EpochEndHeight + if remainingBlocks <= 0 { + return time.Time{}, false + } + return time.Now().Add(time.Duration(remainingBlocks) * defaultEstimatedChainBlockTime), true +} + +func totalStagingBytes(claims []queries.HealClaimRecord) int64 { + var total int64 + for _, claim := range claims { + if strings.TrimSpace(claim.StagingDir) == "" { + continue + } + _ = filepath.WalkDir(claim.StagingDir, func(_ string, d os.DirEntry, err error) error { + if err != nil || d == nil || d.IsDir() { + return nil + } + if info, statErr := d.Info(); statErr == nil { + total += info.Size() + } + return nil + }) + } + return total +} + +func accountInList(account string, list []string) bool { + for _, a := range list { + if a == account { + return true + } + } + return false +} + +func isFinalStatus(s audittypes.HealOpStatus) bool { + switch s { + case audittypes.HealOpStatus_HEAL_OP_STATUS_VERIFIED, + audittypes.HealOpStatus_HEAL_OP_STATUS_FAILED, + audittypes.HealOpStatus_HEAL_OP_STATUS_EXPIRED: + return true + } + return false +} diff --git a/supernode/self_healing/service_test.go b/supernode/self_healing/service_test.go new file mode 100644 index 00000000..97727da1 --- /dev/null +++ b/supernode/self_healing/service_test.go @@ -0,0 +1,793 @@ +package self_healing + +import ( + "context" + "encoding/base64" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/cascadekit" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" + "github.com/LumeraProtocol/supernode/v2/pkg/storage/queries" + cascadeService "github.com/LumeraProtocol/supernode/v2/supernode/cascade" + "lukechampine.com/blake3" +) + +// helper builds a Service + its hooks for testing. Returns Service plus the +// programmable mocks so individual tests can drive scenarios. +type harness struct { + svc *Service + audit *programmableAudit + auditMsg *programmableAuditMsg + cascade *fakeCascadeFactory + store queries.LocalStoreInterface + stagingRoot string + identity string +} + +func newHarness(t *testing.T, identity string, mode audittypes.StorageTruthEnforcementMode) *harness { + t.Helper() + a := newProgrammableAudit(mode) + am := newProgrammableAuditMsg() + cf := newFakeCascadeFactory() + store := newTestStore(t) + root := filepath.Join(t.TempDir(), "heal-staging") + cfg := Config{ + Enabled: true, + PollInterval: time.Second, + MaxConcurrentReconstructs: 2, + MaxConcurrentVerifications: 4, + MaxConcurrentPublishes: 2, + StagingRoot: root, + VerifierFetchAttempts: 2, + VerifierFetchTimeout: time.Second, + VerifierBackoffBase: 10 * time.Millisecond, + KeyName: "test", + } + svc, err := New(identity, cfg, newFakeLumera(a, am), store, cf, &fakeFetcher{}) + if err != nil { + t.Fatalf("New: %v", err) + } + return &harness{svc: svc, audit: a, auditMsg: am, cascade: cf, store: store, stagingRoot: root, identity: identity} +} + +// newTestStore mirrors the test helper in pkg/storage/queries; we re-create +// it here so this package's tests don't depend on internal sqlite test +// scaffolding. +func newTestStore(t *testing.T) queries.LocalStoreInterface { + // Reuse the public OpenHistoryDB by setting HOME to a tempdir so the + // resolved ~/.supernode/history.db lives there. + t.Helper() + tmp := t.TempDir() + old := os.Getenv("HOME") + if err := os.Setenv("HOME", tmp); err != nil { + t.Fatalf("setenv: %v", err) + } + t.Cleanup(func() { _ = os.Setenv("HOME", old) }) + store, err := queries.OpenHistoryDB() + if err != nil { + t.Fatalf("OpenHistoryDB: %v", err) + } + t.Cleanup(func() { store.CloseHistoryDB(context.Background()) }) + return store +} + +func TestCleanupOrphanedStagingDirsRemovesOnlyNumericDirsWithoutClaims(t *testing.T) { + h := newHarness(t, "self", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_SHADOW) + ctx := context.Background() + lep6metrics.Reset() + + pendingHash := hashOf(t, []byte("pending")) + pendingDir := makeStagingDir(t, h.stagingRoot, 101, pendingHash, []byte("pending")) + if err := h.store.RecordPendingHealClaim(ctx, 101, "ticket-101", pendingHash, pendingDir); err != nil { + t.Fatalf("seed pending claim: %v", err) + } + orphanDir := filepath.Join(h.stagingRoot, "202") + if err := os.MkdirAll(orphanDir, 0o700); err != nil { + t.Fatalf("mkdir orphan: %v", err) + } + nonNumericDir := filepath.Join(h.stagingRoot, "not-a-heal-op") + if err := os.MkdirAll(nonNumericDir, 0o700); err != nil { + t.Fatalf("mkdir nonnumeric: %v", err) + } + regularFile := filepath.Join(h.stagingRoot, "303") + if err := os.WriteFile(regularFile, []byte("not a dir"), 0o600); err != nil { + t.Fatalf("write regular file: %v", err) + } + + if err := h.svc.cleanupOrphanedStagingDirs(ctx); err != nil { + t.Fatalf("cleanup: %v", err) + } + + if _, err := os.Stat(pendingDir); err != nil { + t.Fatalf("pending dir must remain: %v", err) + } + if _, err := os.Stat(orphanDir); !os.IsNotExist(err) { + t.Fatalf("orphan dir must be removed, stat err=%v", err) + } + if _, err := os.Stat(nonNumericDir); err != nil { + t.Fatalf("non-numeric dir must remain: %v", err) + } + if _, err := os.Stat(regularFile); err != nil { + t.Fatalf("numeric regular file must remain: %v", err) + } + if got := lep6metrics.Snapshot().HealOrphanedStagingCleanupsTotal; got != 1 { + t.Fatalf("orphan cleanup metric: got %d want 1", got) + } +} + +// fakeFetcher returns a configurable response. Configure per-test by +// reassigning .body / .err. +type fakeFetcher struct { + body []byte + err error +} + +func (f *fakeFetcher) FetchReconstructed(ctx context.Context, healOpID uint64, healerAccount, verifierAccount string) ([]byte, error) { + if f.err != nil { + return nil, f.err + } + return append([]byte(nil), f.body...), nil +} + +// hashOf returns the action.DataHash recipe (BLAKE3 base64) of body. Used as +// the expected op.ResultHash in verifier tests. +func hashOf(t *testing.T, body []byte) string { + t.Helper() + h, err := cascadekit.ComputeBlake3DataHashB64(body) + if err != nil { + t.Fatalf("hash: %v", err) + } + return h +} + +// --------------------------------------------------------------------------- +// Test 1 — TestVerifier_ReadsOpResultHashForComparison (R-bug regression). +// --------------------------------------------------------------------------- +// +// Spec: verifier MUST submit verified=true only when its computed hash +// equals op.ResultHash (chain enforcement at msg_storage_truth.go:291). +// The supernode does not read Action.DataHash anywhere in the heal flow, +// so the regression surface is "do we read op.ResultHash and compare +// against THAT?". This test gives the verifier a body whose hash matches +// op.ResultHash and asserts verified=true with VerificationHash equal to +// the computed hash. A regression that hard-coded a constant or pulled +// from a different field would fail this test. +func TestVerifier_ReadsOpResultHashForComparison(t *testing.T) { + h := newHarness(t, "sn-verifier", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + + body := []byte("recovered-bytes-OK") + // The whole point of the R-bug pin: op.ResultHash is what the healer + // reported; verifier must compare against THIS. + h.audit.put(audittypes.HealOp{ + HealOpId: 10, + TicketId: "ticket-x", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-healer", + VerifierSupernodeAccounts: []string{"sn-verifier"}, + ResultHash: hashOf(t, body), + }) + h.svc.fetcher = &fakeFetcher{body: body} + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + waitForVerifications(t, h.auditMsg, 1) + _, vc := h.auditMsg.snapshot() + if len(vc) != 1 { + t.Fatalf("expected 1 verification call, got %d", len(vc)) + } + if !vc[0].Verified { + t.Fatalf("expected verified=true (computed==op.ResultHash); details=%q", vc[0].Details) + } + if vc[0].VerificationHash != hashOf(t, body) { + t.Fatalf("VerificationHash should equal computed hash; got %q want %q", vc[0].VerificationHash, hashOf(t, body)) + } +} + +// --------------------------------------------------------------------------- +// Test 2 — TestVerifier_HashMismatchProducesVerifiedFalse. +// --------------------------------------------------------------------------- +func TestVerifier_HashMismatchProducesVerifiedFalse(t *testing.T) { + h := newHarness(t, "sn-verifier", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + wantBody := []byte("expected-body") + gotBody := []byte("tampered-body") + h.audit.put(audittypes.HealOp{ + HealOpId: 11, + TicketId: "ticket-y", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-healer", + VerifierSupernodeAccounts: []string{"sn-verifier"}, + ResultHash: hashOf(t, wantBody), + }) + h.svc.fetcher = &fakeFetcher{body: gotBody} + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + waitForVerifications(t, h.auditMsg, 1) + _, vc := h.auditMsg.snapshot() + if vc[0].Verified { + t.Fatalf("expected verified=false on hash mismatch") + } + if !strings.Contains(vc[0].Details, "hash_mismatch") { + t.Fatalf("expected details to mention hash_mismatch, got %q", vc[0].Details) + } + if vc[0].VerificationHash == "" { + t.Fatalf("VerificationHash must be non-empty even on negative votes (chain rejects empty)") + } +} + +// --------------------------------------------------------------------------- +// Test 2b — TestVerifier_FetchFailureSubmitsNonEmptyHash. +// --------------------------------------------------------------------------- +// +// BLOCKER fix regression: chain rejects empty VerificationHash even on +// verified=false (msg_storage_truth.go:271-273). When the verifier can't +// reach the healer, it MUST synthesize a non-empty placeholder hash so the +// negative attestation is well-formed. +func TestVerifier_FetchFailureSubmitsNonEmptyHash(t *testing.T) { + h := newHarness(t, "sn-verifier", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + h.audit.put(audittypes.HealOp{ + HealOpId: 13, + TicketId: "ticket-fetch-fail", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-unreachable-healer", + VerifierSupernodeAccounts: []string{"sn-verifier"}, + ResultHash: hashOf(t, []byte("expected")), + }) + h.svc.fetcher = &fakeFetcher{err: errors.New("connection refused")} + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + waitForVerifications(t, h.auditMsg, 1) + _, vc := h.auditMsg.snapshot() + if vc[0].Verified { + t.Fatalf("expected verified=false on fetch failure") + } + if vc[0].VerificationHash == "" { + t.Fatalf("BLOCKER regression: VerificationHash must be non-empty (chain rejects empty for both positive and negative)") + } + if !strings.Contains(vc[0].Details, "fetch_failed") { + t.Fatalf("details should record reason; got %q", vc[0].Details) + } +} + +func TestNegativeAttestationHashUsesBlake3Convention(t *testing.T) { + reason := "fetch_failed:connection refused" + sum := blake3.Sum256([]byte("lep6:negative-attestation:" + reason)) + want := base64.StdEncoding.EncodeToString(sum[:]) + if got := negativeAttestationHash(reason); got != want { + t.Fatalf("negative attestation hash must use BLAKE3/base64 per LEP-6 storage hash convention; got %q want %q", got, want) + } +} + +func TestDispatcher_StuckScheduledQueryDoesNotStarveVerifier(t *testing.T) { + h := newHarness(t, "sn-verifier", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + h.svc.cfg.AuditQueryTimeout = 20 * time.Millisecond + h.audit.blockStatus[audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED] = true + + body := []byte("verified-even-when-scheduled-query-hangs") + h.audit.put(audittypes.HealOp{ + HealOpId: 14, + TicketId: "ticket-verifier-not-starved", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-healer", + VerifierSupernodeAccounts: []string{"sn-verifier"}, + ResultHash: hashOf(t, body), + }) + h.svc.fetcher = &fakeFetcher{body: body} + + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick should continue past a timed-out scheduled query: %v", err) + } + waitForVerifications(t, h.auditMsg, 1) + _, vc := h.auditMsg.snapshot() + if len(vc) != 1 || !vc[0].Verified { + t.Fatalf("expected verifier dispatch despite stuck scheduled query, got %+v", vc) + } +} + +// --------------------------------------------------------------------------- +// Test 3 — TestVerifier_FetchesFromAssignedHealerOnly (§19 gate). +// --------------------------------------------------------------------------- +// +// Verifier passes (op.HealerSupernodeAccount, identity) to the fetcher and +// nothing else. Verifier must never address an arbitrary peer or KAD. +func TestVerifier_FetchesFromAssignedHealerOnly(t *testing.T) { + h := newHarness(t, "sn-verifier", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("payload") + h.audit.put(audittypes.HealOp{ + HealOpId: 12, + TicketId: "ticket-z", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-healer-7", + VerifierSupernodeAccounts: []string{"sn-verifier", "sn-other"}, + ResultHash: hashOf(t, body), + }) + rec := &recordingFetcher{body: body} + h.svc.fetcher = rec + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + waitForVerifications(t, h.auditMsg, 1) + if rec.lastHealer != "sn-healer-7" { + t.Fatalf("verifier addressed wrong healer: got %q want sn-healer-7", rec.lastHealer) + } + if rec.lastVerifier != "sn-verifier" { + t.Fatalf("verifier identity not propagated: got %q", rec.lastVerifier) + } + if rec.calls != 1 { + t.Fatalf("expected exactly 1 fetch call, got %d", rec.calls) + } +} + +type recordingFetcher struct { + body []byte + lastHealer string + lastVerifier string + calls int +} + +func (r *recordingFetcher) FetchReconstructed(ctx context.Context, healOpID uint64, healerAccount, verifierAccount string) ([]byte, error) { + r.lastHealer = healerAccount + r.lastVerifier = verifierAccount + r.calls++ + return append([]byte(nil), r.body...), nil +} + +// --------------------------------------------------------------------------- +// Tests 4 + 5 — transport handler authorization. +// --------------------------------------------------------------------------- +// Implemented in handler_test.go (transport package). + +// --------------------------------------------------------------------------- +// Test 6 — TestHealer_FailedSubmitDoesNotPersistDedupRow. +// --------------------------------------------------------------------------- +// +// Crash-recovery contract: SubmitClaim is the source of truth — only when +// the chain has accepted the claim is the SQLite dedup row written. A +// failed submit (mempool full, signing error, chain reject) leaves NO row, +// so the next tick can retry cleanly. Reverse ordering would strand the +// op forever on flaky submits, so this test pins the ordering. +// +// Companion: when chain has already accepted a prior submit but the +// supernode crashed before persisting, reconcileExistingClaim queries +// GetHealOp on resubmit-error and persists the row when ResultHash matches. +// That recovery path is exercised separately. +func TestHealer_FailedSubmitDoesNotPersistDedupRow(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("recovered-payload") + wantHash := hashOf(t, body) + h.cascade.reseedFn = func(ctx context.Context, req *cascadeService.RecoveryReseedRequest) (*cascadeService.RecoveryReseedResult, error) { + // Simulate stageArtefacts side-effect: write reconstructed file + + // minimal manifest under StagingDir. + _ = makeStagingDir(t, h.stagingRoot, 20, wantHash, body) + return &cascadeService.RecoveryReseedResult{ + ActionID: req.ActionID, + DataHashVerified: true, + ReconstructedHashB64: wantHash, + StagingDir: req.StagingDir, + }, nil + } + // Simulate a non-state-error submit failure (e.g. mempool full). + h.auditMsg.claimErr = errors.New("simulated mempool full") + h.audit.put(audittypes.HealOp{ + HealOpId: 20, + TicketId: "ticket-q", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-healer", + ResultHash: "", + }) + _ = h.svc.tick(context.Background()) + // Wait for the goroutine to finish. + time.Sleep(200 * time.Millisecond) + // No row should have been written (chain didn't accept). + has, _ := h.store.HasHealClaim(context.Background(), 20) + if has { + t.Fatalf("dedup row must NOT exist when chain submit failed; row found") + } + // Staging dir should be cleaned up so the next tick starts fresh. + stagingDir := filepath.Join(h.stagingRoot, "20") + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging dir should be removed on submit failure; stat err=%v", err) + } +} + +// --------------------------------------------------------------------------- +// Test 6b — TestHealer_ReconcilesExistingChainClaimAfterCrash. +// --------------------------------------------------------------------------- +// +// Crash-recovery: prior submit succeeded but supernode crashed before +// persisting. Resubmit returns "does not accept healer completion claim" +// (chain advanced past SCHEDULED). reconcileExistingClaim must: +// - re-fetch the heal-op +// - confirm chain ResultHash equals our manifest +// - persist the dedup row so finalizer can take over +func TestHealer_ReconcilesExistingChainClaimAfterCrash(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("recovered-payload-22") + wantHash := hashOf(t, body) + h.cascade.reseedFn = func(ctx context.Context, req *cascadeService.RecoveryReseedRequest) (*cascadeService.RecoveryReseedResult, error) { + _ = makeStagingDir(t, h.stagingRoot, 22, wantHash, body) + return &cascadeService.RecoveryReseedResult{ + ActionID: req.ActionID, + DataHashVerified: true, + ReconstructedHashB64: wantHash, + StagingDir: req.StagingDir, + }, nil + } + // Simulate chain having already accepted a previous submit. + h.auditMsg.claimErr = errors.New("rpc error: code = Unknown desc = heal op status HEAL_OP_STATUS_HEALER_REPORTED does not accept healer completion claim") + // Heal-op is in HEALER_REPORTED with our manifest hash. + h.audit.put(audittypes.HealOp{ + HealOpId: 22, + TicketId: "ticket-r", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-healer", + ResultHash: wantHash, + }) + // Note: dispatchHealerOps filters on SCHEDULED, so we drive the + // reconcile path directly via reconstructAndClaim. + op := audittypes.HealOp{ + HealOpId: 22, + TicketId: "ticket-r", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, // healer's local view + HealerSupernodeAccount: "sn-healer", + } + if err := h.svc.reconstructAndClaim(context.Background(), op); err != nil { + t.Fatalf("reconstructAndClaim: %v", err) + } + has, _ := h.store.HasHealClaim(context.Background(), 22) + if !has { + t.Fatalf("reconcile must persist dedup row when chain ResultHash matches manifest") + } +} + +func TestHealer_UnclassifiedSubmitErrorQueriesChainBeforeCleanup(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("recovered-payload-unclassified") + wantHash := hashOf(t, body) + h.cascade.reseedFn = func(ctx context.Context, req *cascadeService.RecoveryReseedRequest) (*cascadeService.RecoveryReseedResult, error) { + _ = makeStagingDir(t, h.stagingRoot, 24, wantHash, body) + return &cascadeService.RecoveryReseedResult{ + ActionID: req.ActionID, + DataHashVerified: true, + ReconstructedHashB64: wantHash, + StagingDir: req.StagingDir, + }, nil + } + // This is intentionally not a typed invalid-state error and not a + // transient gRPC code. It models the C3 lost/garbled-ack window where + // BroadcastTx may have committed, but the client receives an opaque wrapper. + h.auditMsg.claimErr = errors.New("opaque broadcast failure after commit") + h.audit.put(audittypes.HealOp{ + HealOpId: 24, + TicketId: "ticket-unclassified", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-healer", + ResultHash: wantHash, + }) + op := audittypes.HealOp{ + HealOpId: 24, + TicketId: "ticket-unclassified", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-healer", + } + if err := h.svc.reconstructAndClaim(context.Background(), op); err != nil { + t.Fatalf("reconstructAndClaim: %v", err) + } + has, _ := h.store.HasHealClaim(context.Background(), 24) + if !has { + t.Fatalf("unclassified submit error must reconcile accepted chain claim before cleanup") + } + stagingDir := filepath.Join(h.stagingRoot, "24") + if _, err := os.Stat(stagingDir); err != nil { + t.Fatalf("staging dir must remain for finalizer after accepted chain claim: %v", err) + } +} + +func TestHealer_ReconcileHashMismatchCleansStagingWithoutPersisting(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("recovered-payload-23") + wantHash := hashOf(t, body) + stagingDir := makeStagingDir(t, h.stagingRoot, 23, wantHash, body) + h.audit.put(audittypes.HealOp{ + HealOpId: 23, + TicketId: "ticket-s", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-healer", + ResultHash: "different-manifest", + }) + if err := h.svc.reconcileExistingClaim(context.Background(), audittypes.HealOp{HealOpId: 23, TicketId: "ticket-s"}, wantHash, stagingDir); err != nil { + t.Fatalf("reconcileExistingClaim: %v", err) + } + has, _ := h.store.HasHealClaim(context.Background(), 23) + if has { + t.Fatalf("hash mismatch must not persist dedup row") + } + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging dir should be removed on hash mismatch; stat err=%v", err) + } +} + +// --------------------------------------------------------------------------- +// Test 7 — TestHealer_RaptorQReconstructionFailureSkipsClaim (Scenario C1). +// --------------------------------------------------------------------------- +func TestHealer_RaptorQReconstructionFailureSkipsClaim(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + h.cascade.reseedFn = func(ctx context.Context, req *cascadeService.RecoveryReseedRequest) (*cascadeService.RecoveryReseedResult, error) { + return nil, errors.New("RaptorQ decode failed: insufficient symbols") + } + h.audit.put(audittypes.HealOp{ + HealOpId: 21, + TicketId: "ticket-broken", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-healer", + }) + _ = h.svc.tick(context.Background()) + // Sleep briefly to let the goroutine run. + time.Sleep(200 * time.Millisecond) + if h.auditMsg.claimsCount.Load() != 0 { + t.Fatalf("expected zero claim submissions; got %d", h.auditMsg.claimsCount.Load()) + } + has, _ := h.store.HasHealClaim(context.Background(), 21) + if has { + t.Fatalf("no row should be persisted on reconstruction failure") + } +} + +// --------------------------------------------------------------------------- +// Test 8 — TestFinalizer_VerifiedTriggersPublishToKAD. +// --------------------------------------------------------------------------- +func TestFinalizer_VerifiedTriggersPublishToKAD(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + hash := hashOf(t, []byte("body")) + stagingDir := makeStagingDir(t, h.stagingRoot, 30, hash, []byte("body")) + // Pre-seed the dedup row. + if err := h.store.RecordHealClaim(context.Background(), 30, "ticket-30", hash, stagingDir); err != nil { + t.Fatalf("seed claim: %v", err) + } + h.audit.put(audittypes.HealOp{HealOpId: 30, TicketId: "ticket-30", Status: audittypes.HealOpStatus_HEAL_OP_STATUS_VERIFIED, ResultHash: hash}) + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + waitForCondition(t, 2*time.Second, func() bool { + return h.cascade.publishCalls.Load() == 1 + }) + if got := h.cascade.lastPublishedDir.Load().(string); got != stagingDir { + t.Fatalf("published wrong dir: got %q want %q", got, stagingDir) + } + // Row must be deleted after successful publish. + has, _ := h.store.HasHealClaim(context.Background(), 30) + if has { + t.Fatalf("dedup row should be deleted after publish") + } + // Staging dir cleaned. + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging dir should be removed after publish; stat err=%v", err) + } +} + +// --------------------------------------------------------------------------- +// Test 9 — TestFinalizer_FailedSkipsPublish_DeletesStaging. +// --------------------------------------------------------------------------- +func TestFinalizer_FailedSkipsPublish_DeletesStaging(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + hash := hashOf(t, []byte("x")) + stagingDir := makeStagingDir(t, h.stagingRoot, 31, hash, []byte("x")) + if err := h.store.RecordHealClaim(context.Background(), 31, "ticket-31", hash, stagingDir); err != nil { + t.Fatalf("seed: %v", err) + } + h.audit.put(audittypes.HealOp{HealOpId: 31, TicketId: "ticket-31", Status: audittypes.HealOpStatus_HEAL_OP_STATUS_FAILED}) + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + waitForCondition(t, 2*time.Second, func() bool { + has, _ := h.store.HasHealClaim(context.Background(), 31) + return !has + }) + if h.cascade.publishCalls.Load() != 0 { + t.Fatalf("publish must not be called on FAILED") + } + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging should be removed on FAILED") + } +} + +// --------------------------------------------------------------------------- +// Test 10 — TestFinalizer_ExpiredSkipsPublish_DeletesStaging. +// --------------------------------------------------------------------------- +func TestFinalizer_ExpiredSkipsPublish_DeletesStaging(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + hash := hashOf(t, []byte("y")) + stagingDir := makeStagingDir(t, h.stagingRoot, 32, hash, []byte("y")) + if err := h.store.RecordHealClaim(context.Background(), 32, "ticket-32", hash, stagingDir); err != nil { + t.Fatalf("seed: %v", err) + } + h.audit.put(audittypes.HealOp{HealOpId: 32, Status: audittypes.HealOpStatus_HEAL_OP_STATUS_EXPIRED}) + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + waitForCondition(t, 2*time.Second, func() bool { + has, _ := h.store.HasHealClaim(context.Background(), 32) + return !has + }) + if h.cascade.publishCalls.Load() != 0 { + t.Fatalf("publish must not be called on EXPIRED") + } + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging should be removed on EXPIRED") + } +} + +func TestFinalizer_NotFoundCleansClaimAndStaging(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + hash := hashOf(t, []byte("pruned")) + stagingDir := makeStagingDir(t, h.stagingRoot, 33, hash, []byte("pruned")) + if err := h.store.RecordHealClaim(context.Background(), 33, "ticket-33", hash, stagingDir); err != nil { + t.Fatalf("seed: %v", err) + } + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + waitForCondition(t, 2*time.Second, func() bool { + has, _ := h.store.HasHealClaim(context.Background(), 33) + return !has + }) + if h.cascade.publishCalls.Load() != 0 { + t.Fatalf("publish must not be called when chain heal-op is not found") + } + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging should be removed when heal-op is not found") + } +} + +// --------------------------------------------------------------------------- +// Test 11 — TestService_NoRoleSkipsOp. +// --------------------------------------------------------------------------- +func TestService_NoRoleSkipsOp(t *testing.T) { + h := newHarness(t, "sn-bystander", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + h.audit.put(audittypes.HealOp{ + HealOpId: 40, + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-other-healer", + VerifierSupernodeAccounts: []string{"sn-v1", "sn-v2"}, + }) + h.audit.put(audittypes.HealOp{ + HealOpId: 41, + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-other-healer", + VerifierSupernodeAccounts: []string{"sn-v1", "sn-v2"}, + ResultHash: "any", + }) + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + time.Sleep(150 * time.Millisecond) + if h.cascade.reseedCalls.Load() != 0 { + t.Fatalf("non-assigned supernode must not reconstruct") + } + if h.auditMsg.claimsCount.Load() != 0 || h.auditMsg.verificationsCount.Load() != 0 { + t.Fatalf("no tx should be submitted by non-assigned supernode") + } +} + +// --------------------------------------------------------------------------- +// Test 12 — TestService_UnspecifiedModeSkipsEntirely. +// --------------------------------------------------------------------------- +func TestService_UnspecifiedModeSkipsEntirely(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED) + // Even ops we'd otherwise be assigned to. + h.audit.put(audittypes.HealOp{ + HealOpId: 50, + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-healer", + }) + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + time.Sleep(150 * time.Millisecond) + if h.cascade.reseedCalls.Load() != 0 { + t.Fatalf("UNSPECIFIED mode must skip dispatcher entirely") + } +} + +// --------------------------------------------------------------------------- +// Test 13 — TestService_FinalStateOpsIgnored. +// --------------------------------------------------------------------------- +func TestService_FinalStateOpsIgnored(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + // Even with sn-healer assigned, VERIFIED/FAILED/EXPIRED are filtered out + // at the dispatcher level (status != SCHEDULED, status != HEALER_REPORTED). + h.audit.put(audittypes.HealOp{HealOpId: 60, Status: audittypes.HealOpStatus_HEAL_OP_STATUS_VERIFIED, HealerSupernodeAccount: "sn-healer"}) + h.audit.put(audittypes.HealOp{HealOpId: 61, Status: audittypes.HealOpStatus_HEAL_OP_STATUS_FAILED, HealerSupernodeAccount: "sn-healer"}) + h.audit.put(audittypes.HealOp{HealOpId: 62, Status: audittypes.HealOpStatus_HEAL_OP_STATUS_EXPIRED, HealerSupernodeAccount: "sn-healer"}) + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + time.Sleep(150 * time.Millisecond) + if h.cascade.reseedCalls.Load() != 0 { + t.Fatalf("final-state ops must not trigger reconstruction") + } + if h.auditMsg.claimsCount.Load() != 0 { + t.Fatalf("no claim submissions for final-state ops") + } +} + +// --------------------------------------------------------------------------- +// Test 14 — TestDedup_RestartDoesNotResubmit. +// --------------------------------------------------------------------------- +func TestDedup_RestartDoesNotResubmit(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + hash := hashOf(t, []byte("body")) + stagingDir := makeStagingDir(t, h.stagingRoot, 70, hash, []byte("body")) + // Simulate a prior tick that already persisted + submitted. + if err := h.store.RecordHealClaim(context.Background(), 70, "ticket-70", hash, stagingDir); err != nil { + t.Fatalf("seed: %v", err) + } + // New tick sees op in SCHEDULED (chain hasn't seen the tx in the simulator, + // but supernode dedup must short-circuit). + h.audit.put(audittypes.HealOp{HealOpId: 70, TicketId: "ticket-70", Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, HealerSupernodeAccount: "sn-healer"}) + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + time.Sleep(150 * time.Millisecond) + if h.cascade.reseedCalls.Load() != 0 { + t.Fatalf("restart must NOT re-run RaptorQ for an already-claimed op") + } + if h.auditMsg.claimsCount.Load() != 0 { + t.Fatalf("restart must NOT resubmit claim tx") + } + // And same property for verifier dedup: + hv := newHarness(t, "sn-verifier", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + if err := hv.store.RecordHealVerification(context.Background(), 71, "sn-verifier", true, hash); err != nil { + t.Fatalf("seed verification: %v", err) + } + hv.audit.put(audittypes.HealOp{ + HealOpId: 71, + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + HealerSupernodeAccount: "sn-h", + VerifierSupernodeAccounts: []string{"sn-verifier"}, + ResultHash: hash, + }) + hv.svc.fetcher = &fakeFetcher{body: []byte("body")} + if err := hv.svc.tick(context.Background()); err != nil { + t.Fatalf("tick verifier: %v", err) + } + time.Sleep(150 * time.Millisecond) + if hv.auditMsg.verificationsCount.Load() != 0 { + t.Fatalf("restart must NOT resubmit verification tx") + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func waitForVerifications(t *testing.T, am *programmableAuditMsg, want int64) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if am.verificationsCount.Load() >= want { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timeout waiting for %d verifications; got %d", want, am.verificationsCount.Load()) +} + +func waitForCondition(t *testing.T, timeout time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timeout waiting for condition") +} diff --git a/supernode/self_healing/verifier.go b/supernode/self_healing/verifier.go new file mode 100644 index 00000000..63e78626 --- /dev/null +++ b/supernode/self_healing/verifier.go @@ -0,0 +1,234 @@ +package self_healing + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/cascadekit" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/chainerrors" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" + "github.com/LumeraProtocol/supernode/v2/pkg/storage/queries" + "lukechampine.com/blake3" +) + +// verifyAndSubmit runs LEP-6 §19 Phase 2 for one heal-op. +// +// Critical correctness rules +// +// 1. The verifier MUST fetch from the assigned healer (op.HealerSupernode +// Account), not from KAD. KAD is empty during HEALER_REPORTED — the +// healer publishes only after VERIFIED — so reading from KAD would +// loop on miss. More importantly, the §19 healer-served path is the +// only authority before chain quorum. +// +// 2. The verifier MUST compare its computed hash against op.ResultHash +// (set by the chain from the healer's HealManifestHash), NOT against +// Action.DataHash. The chain enforces this at +// lumera/x/audit/v1/keeper/msg_storage_truth.go:291. A verifier that +// submits VerificationHash != op.ResultHash with verified=true is +// rejected by the chain. Pinned by TestVerifier_ReadsOpResultHashForComparison. +// +// 3. On fetch failure after VerifierFetchAttempts retries the verifier +// submits verified=false. The chain rejects empty VerificationHash even +// for negatives (msg_storage_truth.go:271-273), so we synthesize a +// non-empty deterministic placeholder hash — for negative attestations +// the chain only validates equality when `req.Verified == true` +// (msg_storage_truth.go:288-294), so any non-empty value is accepted. +// +// 4. Persist-AFTER-submit ordering: SQLite dedup row is written ONLY after +// the chain accepted the tx. A failed submit therefore leaves no row, +// letting the next tick retry. Reverse ordering would strand the op +// forever on flaky submits. +func (s *Service) verifyAndSubmit(ctx context.Context, op audittypes.HealOp) error { + if err := s.semVerify.Acquire(ctx, 1); err != nil { + return err + } + defer s.semVerify.Release(1) + + expectedHash := strings.TrimSpace(op.ResultHash) + if expectedHash == "" { + return fmt.Errorf("op.ResultHash empty (op not in HEALER_REPORTED?)") + } + + // H1 fix: pre-check deadline before fetch + submit. Same fee-burn + // concern as the healer path; chain rejects past-deadline submits + // via ErrHealOpInvalidState. + if expired, expErr := s.healOpDeadlinePassed(ctx, op); expErr != nil { + logtrace.Warn(ctx, "self_healing(LEP-6): could not check verifier deadline; proceeding", logtrace.Fields{ + "heal_op_id": op.HealOpId, + logtrace.FieldError: expErr.Error(), + }) + } else if expired { + logtrace.Warn(ctx, "self_healing(LEP-6): heal op deadline passed before verifier submit; skipping", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "deadline": op.DeadlineEpochId, + }) + return nil + } + + bytesGot, fetchErr := s.fetchFromHealerWithRetry(ctx, op) + if fetchErr != nil { + // Submit negative verification with a non-empty placeholder hash — + // chain rejects empty VerificationHash even for negative votes. + // L2 fix: hash a canonical reason taxonomy instead of the raw + // fetchErr string so audit trails can correlate identical failure + // modes across verifiers. + details := negativeReasonFetchFailed + ":" + fetchErr.Error() + if err := s.submitNegativeWithReason(ctx, op.HealOpId, negativeReasonFetchFailed, details); err != nil { + return fmt.Errorf("fetch %v; submit-negative %w", fetchErr, err) + } + logtrace.Warn(ctx, "self_healing(LEP-6): verifier submitted negative due to fetch failure", logtrace.Fields{ + "heal_op_id": op.HealOpId, + logtrace.FieldError: fetchErr.Error(), + }) + return nil + } + + computedHash, hashErr := cascadekit.ComputeBlake3DataHashB64(bytesGot) + if hashErr != nil { + details := negativeReasonHashCompute + ":" + hashErr.Error() + if err := s.submitNegativeWithReason(ctx, op.HealOpId, negativeReasonHashCompute, details); err != nil { + return fmt.Errorf("hash %v; submit-negative %w", hashErr, err) + } + return nil + } + verified := computedHash == expectedHash + details := "" + if !verified { + details = negativeReasonHashMismatch + } + // Positive: chain validates VerificationHash == op.ResultHash. Negative: + // chain accepts any non-empty hash. Send computedHash either way so audit + // trails always carry the verifier's own observation. + if err := s.submitVerification(ctx, op.HealOpId, verified, computedHash, details); err != nil { + return fmt.Errorf("submit verification: %w", err) + } + logtrace.Info(ctx, "self_healing(LEP-6): verification submitted", logtrace.Fields{ + "heal_op_id": op.HealOpId, + "verified": verified, + "expected_h": expectedHash, + "computed_h": computedHash, + "bytes_length": len(bytesGot), + }) + return nil +} + +// L2 fix: small canonical reason taxonomy for negative attestations. +// Hashing the canonical reason (instead of raw error text) means two +// verifiers that observed the same failure class produce the same +// negative-attestation hash — easier audit-trail correlation. +const ( + negativeReasonFetchFailed = "reason_fetch_failed" + negativeReasonHashCompute = "reason_hash_compute_failed" + negativeReasonHashMismatch = "reason_hash_mismatch" + negativeReasonOther = "reason_other" +) + +// submitNegativeWithReason synthesizes a deterministic non-empty placeholder +// hash from the canonical reason category and submits a negative +// verification. Chain only validates VerificationHash content for positive +// votes (msg_storage_truth.go:288-294), so any non-empty value is well- +// formed. `details` carries the full free-form context (raw error / file +// info) for the chain log; the on-chain hash is derived from the canonical +// reason only. +func (s *Service) submitNegativeWithReason(ctx context.Context, healOpID uint64, reasonCategory, details string) error { + if reasonCategory == "" { + reasonCategory = negativeReasonOther + } + placeholder := negativeAttestationHash(reasonCategory) + return s.submitVerification(ctx, healOpID, false, placeholder, details) +} + +// negativeAttestationHash returns a stable non-empty BLAKE3/base64 hash +// derived from the canonical reason category. Format remains a 32-byte +// digest encoded as base64 so downstream consumers don't have to special- +// case width. Wave 2 / L2 fix: input is now a small enum string instead of +// raw fetchErr.Error() — verifiers observing the same failure class +// produce the same negative hash, allowing chain-side correlation. +func negativeAttestationHash(reasonCategory string) string { + sum := blake3.Sum256([]byte("lep6:negative-attestation:" + reasonCategory)) + return base64.StdEncoding.EncodeToString(sum[:]) +} + +// submitVerification pre-stages the SQLite dedup row before submitting +// MsgSubmitHealVerification, then marks it submitted after chain acceptance. +// This closes the submit-success/persist-crash window without weakening +// chain authority: on hard tx failure we remove only the pending row so the +// verifier can retry later. +func (s *Service) submitVerification(ctx context.Context, healOpID uint64, verified bool, hash, details string) error { + if err := s.store.RecordPendingHealVerification(ctx, healOpID, s.identity, verified, hash); err != nil { + if errors.Is(err, queries.ErrLEP6VerificationAlreadyRecorded) { + lep6metrics.IncHealVerification("dedup", verified) + lep6metrics.IncHealVerificationAlreadyExists() + return nil + } + lep6metrics.IncHealVerification("stage_error", verified) + return fmt.Errorf("stage heal verification before submit: %w", err) + } + + resp, err := s.lumera.AuditMsg().SubmitHealVerification(ctx, healOpID, verified, hash, details) + if err != nil { + // Transient gRPC failures MUST NOT delete the pending row — the + // next tick will retry and reach idempotent dedup on the chain. + if chainerrors.IsTransientGrpc(err) { + lep6metrics.IncHealVerification("submit_transient", verified) + return fmt.Errorf("submit verification (transient, will retry): %w", err) + } + if chainerrors.IsHealVerificationAlreadySubmitted(err) { + if markErr := s.store.MarkHealVerificationSubmitted(ctx, healOpID, s.identity); markErr != nil { + return fmt.Errorf("mark reconciled verification submitted: %w", markErr) + } + return nil + } + _ = s.store.DeletePendingHealVerification(ctx, healOpID, s.identity) + lep6metrics.IncHealVerification("submit_error", verified) + return err + } + _ = resp + if err := s.store.MarkHealVerificationSubmitted(ctx, healOpID, s.identity); err != nil { + lep6metrics.IncHealVerification("mark_error", verified) + return fmt.Errorf("mark heal verification submitted: %w", err) + } + lep6metrics.IncHealVerification("submitted", verified) + return nil +} + +// (Wave 0): isChainVerificationAlreadyExists helper removed; classification +// is centralised in pkg/lumera/chainerrors.IsHealVerificationAlreadySubmitted +// which uses typed sentinel matching (audittypes.ErrHealVerificationExists) +// with substring fallback, and the call site short-circuits on +// IsTransientGrpc to preserve the pending row across transient failures. + +// fetchFromHealerWithRetry is the §19 healer-served-path GET with bounded +// exponential backoff. Returns the reconstructed file bytes (concatenated +// from chunks if chunked). +func (s *Service) fetchFromHealerWithRetry(ctx context.Context, op audittypes.HealOp) ([]byte, error) { + if s.fetcher == nil { + return nil, fmt.Errorf("verifier fetcher is nil") + } + var lastErr error + for attempt := 0; attempt < s.cfg.VerifierFetchAttempts; attempt++ { + fetchCtx, cancel := context.WithTimeout(ctx, s.cfg.VerifierFetchTimeout) + bytesGot, err := s.fetcher.FetchReconstructed(fetchCtx, op.HealOpId, op.HealerSupernodeAccount, s.identity) + cancel() + if err == nil { + return bytesGot, nil + } + lastErr = err + if attempt+1 < s.cfg.VerifierFetchAttempts { + delay := s.cfg.VerifierBackoffBase * (1 << attempt) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay): + } + } + } + return nil, lastErr +} diff --git a/supernode/self_healing/wave2_constants_test.go b/supernode/self_healing/wave2_constants_test.go new file mode 100644 index 00000000..481fd596 --- /dev/null +++ b/supernode/self_healing/wave2_constants_test.go @@ -0,0 +1,51 @@ +package self_healing + +import ( + "testing" +) + +// TestMaxReconstructedBytesConstant pins the H7 verifier-side cap so a +// future refactor can't silently zero it. 4 GiB is intentionally large +// enough to accommodate any real cascade action while bounding the +// worst-case verifier RAM footprint. +func TestMaxReconstructedBytesConstant(t *testing.T) { + const minSane uint64 = 64 * 1024 * 1024 // 64 MiB lower bound + if MaxReconstructedBytes < minSane { + t.Fatalf("MaxReconstructedBytes=%d under sane lower bound %d", MaxReconstructedBytes, minSane) + } + if MaxReconstructedBytes == 0 { + t.Fatalf("MaxReconstructedBytes must NOT be zero — that disables the H7 cap") + } +} + +// TestNegativeAttestationTaxonomy pins the L2 canonical reason set so +// adding a new reason category requires touching this test (and ensures +// the constants stay non-empty). +func TestNegativeAttestationTaxonomy(t *testing.T) { + cases := []struct { + name, val string + }{ + {"fetch_failed", negativeReasonFetchFailed}, + {"hash_compute_failed", negativeReasonHashCompute}, + {"hash_mismatch", negativeReasonHashMismatch}, + {"other", negativeReasonOther}, + } + seen := map[string]struct{}{} + for _, tc := range cases { + if tc.val == "" { + t.Fatalf("%s: empty taxonomy value", tc.name) + } + if _, dup := seen[tc.val]; dup { + t.Fatalf("%s: duplicate taxonomy value %q", tc.name, tc.val) + } + seen[tc.val] = struct{}{} + // All values share the "reason_" prefix for grep-ability. + if got := negativeAttestationHash(tc.val); got == "" { + t.Fatalf("%s: empty hash for %q", tc.name, tc.val) + } + } + // Two different reason categories must produce different hashes. + if negativeAttestationHash(negativeReasonFetchFailed) == negativeAttestationHash(negativeReasonHashMismatch) { + t.Fatalf("distinct reason categories must produce distinct hashes") + } +} diff --git a/supernode/self_healing/wave2_regression_test.go b/supernode/self_healing/wave2_regression_test.go new file mode 100644 index 00000000..082aaf2c --- /dev/null +++ b/supernode/self_healing/wave2_regression_test.go @@ -0,0 +1,242 @@ +package self_healing + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + cascadeService "github.com/LumeraProtocol/supernode/v2/supernode/cascade" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// TestHealer_DeadlinePassedSkipsSubmit covers the H1 fix: a heal-op whose +// DeadlineEpochId is already in the past must NOT submit (chain would +// reject with ErrHealOpInvalidState anyway). Pre-check saves the gas burn. +func TestHealer_DeadlinePassedSkipsSubmit(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("payload-h1") + wantHash := hashOf(t, body) + h.cascade.reseedFn = func(ctx context.Context, req *cascadeService.RecoveryReseedRequest) (*cascadeService.RecoveryReseedResult, error) { + _ = makeStagingDir(t, h.stagingRoot, 999, wantHash, body) + return &cascadeService.RecoveryReseedResult{ + ActionID: req.ActionID, DataHashVerified: true, + ReconstructedHashB64: wantHash, StagingDir: req.StagingDir, + }, nil + } + // Current epoch already past the heal-op deadline. + h.audit.currentEpoch = 100 + op := audittypes.HealOp{ + HealOpId: 999, TicketId: "ticket-d", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-healer", + DeadlineEpochId: 50, + } + if err := h.svc.reconstructAndClaim(context.Background(), op); err != nil { + t.Fatalf("reconstructAndClaim: %v", err) + } + // No submit attempt was made. + if calls := h.auditMsg.claimCalls; len(calls) != 0 { + t.Fatalf("expected no claim submit on past-deadline op; got %d", len(calls)) + } + // Staging cleaned up. + if _, err := os.Stat(filepath.Join(h.stagingRoot, "999")); !os.IsNotExist(err) { + t.Fatalf("staging should be removed when deadline passed") + } + // No dedup row left behind. + has, _ := h.store.HasHealClaim(context.Background(), 999) + if has { + t.Fatalf("no claim should be persisted on past-deadline skip") + } + pending, _ := h.store.HasPendingHealClaim(context.Background(), 999) + if pending { + t.Fatalf("no pending row should remain on past-deadline skip") + } +} + +// TestHealer_DeadlinePreCheckTransientErrorProceeds covers the +// defense-in-depth case where GetCurrentEpoch fails transiently — we +// proceed with submit (chain will reject if needed) rather than skip a +// possibly-still-valid op. +func TestHealer_DeadlinePreCheckTransientErrorProceeds(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("payload-transient") + wantHash := hashOf(t, body) + h.cascade.reseedFn = func(ctx context.Context, req *cascadeService.RecoveryReseedRequest) (*cascadeService.RecoveryReseedResult, error) { + _ = makeStagingDir(t, h.stagingRoot, 1001, wantHash, body) + return &cascadeService.RecoveryReseedResult{ + ActionID: req.ActionID, DataHashVerified: true, + ReconstructedHashB64: wantHash, StagingDir: req.StagingDir, + }, nil + } + // Make GetCurrentEpoch fail transiently — we override the mock by + // putting a HealOp without deadline so the check returns immediately + // (skipping the GetCurrentEpoch call). DeadlineEpochId=0 means + // "no deadline configured". + op := audittypes.HealOp{ + HealOpId: 1001, TicketId: "ticket-z", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-healer", + DeadlineEpochId: 0, // no deadline → pre-check is a no-op + } + if err := h.svc.reconstructAndClaim(context.Background(), op); err != nil { + t.Fatalf("reconstructAndClaim: %v", err) + } + // Submit happened normally. + if calls := h.auditMsg.claimCalls; len(calls) != 1 { + t.Fatalf("expected 1 claim submit when deadline=0; got %d", len(calls)) + } +} + +// TestFinalizer_RunsEvenInUnspecifiedMode covers the M7 fix: a pending +// claim row + staging dir that survives a governance rollback to +// UNSPECIFIED must still drain via the finalizer. +func TestFinalizer_RunsEvenInUnspecifiedMode(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED) + body := []byte("leftover-after-rollback") + wantHash := hashOf(t, body) + stagingDir := makeStagingDir(t, h.stagingRoot, 70, wantHash, body) + if err := h.store.RecordHealClaim(context.Background(), 70, "ticket-70", wantHash, stagingDir); err != nil { + t.Fatalf("seed claim: %v", err) + } + // Heal-op finalized FAILED on chain. + h.audit.put(audittypes.HealOp{ + HealOpId: 70, + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_FAILED, + }) + // Run a tick under UNSPECIFIED. Healer/verifier dispatch should + // be skipped, but finalizer MUST still run and clean up. + if err := h.svc.tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + waitForCondition(t, 2*time.Second, func() bool { + has, _ := h.store.HasHealClaim(context.Background(), 70) + return !has + }) + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging dir should be cleaned up by finalizer even in UNSPECIFIED mode (M7)") + } +} + +// TestC5_ResumePendingHealClaim_PromotesOnMatchingChain covers the C5 +// promote branch: a `pending` row exists locally; chain has accepted our +// claim (HEALER_REPORTED with matching ResultHash). The dispatcher must +// promote pending → submitted, NOT re-run reconstruct. +func TestC5_ResumePendingHealClaim_PromotesOnMatchingChain(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("c5-payload") + wantHash := hashOf(t, body) + stagingDir := makeStagingDir(t, h.stagingRoot, 555, wantHash, body) + // Pre-seed a pending row (simulating a crash mid-submit). + if err := h.store.RecordPendingHealClaim(context.Background(), 555, "ticket-555", wantHash, stagingDir); err != nil { + t.Fatalf("seed pending: %v", err) + } + // Chain shows our claim was accepted. + h.audit.put(audittypes.HealOp{ + HealOpId: 555, + TicketId: "ticket-555", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, // dispatcher's view + HealerSupernodeAccount: "sn-healer", + }) + // resumePendingHealClaim consults the chain via GetHealOp; override + // its return so chain says HEALER_REPORTED with our hash. + h.audit.setStatus(555, audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED) + chainOp := h.audit.opsByID[555] + chainOp.ResultHash = wantHash + h.audit.put(chainOp) + // Configure cascade reseedFn to fail loudly if invoked — resume must + // NOT run reconstruct. + h.cascade.reseedFn = func(ctx context.Context, _ *cascadeService.RecoveryReseedRequest) (*cascadeService.RecoveryReseedResult, error) { + t.Fatalf("resume path must NOT call reseed (the chain already accepted our claim)") + return nil, errors.New("must not be called") + } + op := audittypes.HealOp{ + HealOpId: 555, + TicketId: "ticket-555", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-healer", + } + if err := h.svc.resumePendingHealClaim(context.Background(), op); err != nil { + t.Fatalf("resumePendingHealClaim: %v", err) + } + // Pending → submitted promotion happened. + has, _ := h.store.HasHealClaim(context.Background(), 555) + if !has { + t.Fatalf("expected submitted row after resume promote") + } + pending, _ := h.store.HasPendingHealClaim(context.Background(), 555) + if pending { + t.Fatalf("pending row should be cleared after promote") + } + // Staging preserved (finalizer needs it). + if _, err := os.Stat(stagingDir); err != nil { + t.Fatalf("staging dir should be preserved on promote: %v", err) + } +} + +// TestC5_ResumePendingHealClaim_ResetOnStillScheduled covers the C5 +// reset branch: chain still SCHEDULED → our prior submit was rejected +// or lost. Drop pending row + staging so next tick re-dispatches fresh. +func TestC5_ResumePendingHealClaim_ResetOnStillScheduled(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("c5-reset-payload") + wantHash := hashOf(t, body) + stagingDir := makeStagingDir(t, h.stagingRoot, 556, wantHash, body) + if err := h.store.RecordPendingHealClaim(context.Background(), 556, "ticket-556", wantHash, stagingDir); err != nil { + t.Fatalf("seed pending: %v", err) + } + h.audit.put(audittypes.HealOp{ + HealOpId: 556, + TicketId: "ticket-556", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-healer", + }) + op := audittypes.HealOp{ + HealOpId: 556, + TicketId: "ticket-556", + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + HealerSupernodeAccount: "sn-healer", + } + if err := h.svc.resumePendingHealClaim(context.Background(), op); err != nil { + t.Fatalf("resumePendingHealClaim: %v", err) + } + // Pending row + staging cleaned up. + pending, _ := h.store.HasPendingHealClaim(context.Background(), 556) + if pending { + t.Fatalf("pending row should be deleted after reset") + } + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging dir should be removed on reset") + } +} + +// TestC5_ResumePendingHealClaim_TransientGrpcPreservesState covers the +// C5 transient-failure branch: a transient gRPC error during the chain +// reconcile query must NOT delete the pending row or staging dir. +func TestC5_ResumePendingHealClaim_TransientGrpcPreservesState(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + body := []byte("c5-transient") + wantHash := hashOf(t, body) + stagingDir := makeStagingDir(t, h.stagingRoot, 557, wantHash, body) + if err := h.store.RecordPendingHealClaim(context.Background(), 557, "ticket-557", wantHash, stagingDir); err != nil { + t.Fatalf("seed pending: %v", err) + } + h.audit.getOpErr = status.Error(codes.Unavailable, "chain unavailable") + op := audittypes.HealOp{HealOpId: 557, TicketId: "ticket-557", HealerSupernodeAccount: "sn-healer"} + err := h.svc.resumePendingHealClaim(context.Background(), op) + if err == nil { + t.Fatalf("expected error on transient gRPC") + } + // Pending + staging preserved. + pending, _ := h.store.HasPendingHealClaim(context.Background(), 557) + if !pending { + t.Fatalf("pending row must NOT be deleted on transient error (C5)") + } + if _, err := os.Stat(stagingDir); err != nil { + t.Fatalf("staging dir must be preserved on transient: %v", err) + } +} diff --git a/supernode/self_healing/wave3_regression_test.go b/supernode/self_healing/wave3_regression_test.go new file mode 100644 index 00000000..39986d76 --- /dev/null +++ b/supernode/self_healing/wave3_regression_test.go @@ -0,0 +1,61 @@ +package self_healing + +import ( + "context" + "os" + "strings" + "testing" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +func TestDispatchOpContextForHealOpUsesEpochAnchorDeadlineWhenEarlier(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + h.svc.cfg.DispatchOpTimeout = time.Hour + h.audit.currentAnchor = audittypes.EpochAnchor{EpochId: 10, EpochEndHeight: 100} + h.audit.epochAnchors[11] = audittypes.EpochAnchor{EpochId: 11, EpochEndHeight: 101} + + ctx, cancel := h.svc.dispatchOpContextForHealOp(context.Background(), audittypes.HealOp{HealOpId: 1, DeadlineEpochId: 11}) + defer cancel() + deadline, ok := ctx.Deadline() + if !ok { + t.Fatalf("expected derived deadline") + } + remaining := time.Until(deadline) + if remaining <= 0 || remaining > 30*time.Second { + t.Fatalf("expected deadline derived from 1 remaining chain block, got %s", remaining) + } +} + +func TestDispatchOpContextForHealOpFallsBackToHardTimeoutWhenAnchorsMissing(t *testing.T) { + h := newHarness(t, "sn-healer", audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL) + h.svc.cfg.DispatchOpTimeout = 50 * time.Millisecond + + ctx, cancel := h.svc.dispatchOpContextForHealOp(context.Background(), audittypes.HealOp{HealOpId: 1, DeadlineEpochId: 99}) + defer cancel() + deadline, ok := ctx.Deadline() + if !ok { + t.Fatalf("expected hard timeout deadline") + } + remaining := time.Until(deadline) + if remaining <= 0 || remaining > time.Second { + t.Fatalf("expected hard timeout fallback, got %s", remaining) + } +} + +func TestPublishStagingDirDeletesClaimBeforeRemovingStagingDir(t *testing.T) { + src, err := os.ReadFile("finalizer.go") + if err != nil { + t.Fatal(err) + } + body := string(src) + deleteIdx := strings.Index(body, "DeleteHealClaim(ctx, claim.HealOpID)") + removeIdx := strings.Index(body, "os.RemoveAll(claim.StagingDir)") + if deleteIdx < 0 || removeIdx < 0 { + t.Fatalf("expected publishStagingDir cleanup calls to exist") + } + if deleteIdx > removeIdx { + t.Fatalf("publishStagingDir must delete durable claim row before removing staging dir") + } +} diff --git a/supernode/status/service.go b/supernode/status/service.go index c0fb5cab..33d15541 100644 --- a/supernode/status/service.go +++ b/supernode/status/service.go @@ -9,6 +9,7 @@ import ( "github.com/LumeraProtocol/supernode/v2/p2p" "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" "github.com/LumeraProtocol/supernode/v2/pkg/task" "github.com/LumeraProtocol/supernode/v2/supernode/config" ) @@ -58,6 +59,42 @@ func (s *SupernodeStatusService) GetChainID() string { return "" } +func lep6StatusMetrics(s lep6metrics.MetricsSnapshot) *pb.StatusResponse_LEP6Metrics { + return &pb.StatusResponse_LEP6Metrics{ + DispatchResultsTotal: cloneUint64Map(s.DispatchResultsTotal), + DispatchThrottledTotal: cloneUint64Map(s.DispatchThrottledTotal), + DispatchEpochDurationMillisTotal: cloneUint64Map(s.DispatchEpochDurationMillisTotal), + DispatchEpochDurationMillisMax: cloneUint64Map(s.DispatchEpochDurationMillisMax), + DispatchEpochDurationCount: cloneUint64Map(s.DispatchEpochDurationCount), + TicketDiscoveryTotal: cloneUint64Map(s.TicketDiscoveryTotal), + NoTicketProviderActive: s.NoTicketProviderActive, + HealClaimsSubmittedTotal: cloneUint64Map(s.HealClaimsSubmittedTotal), + HealClaimsReconciledTotal: s.HealClaimsReconciledTotal, + HealVerificationsSubmittedTotal: cloneUint64Map(s.HealVerificationsSubmittedTotal), + HealVerificationsAlreadyExistsTotal: s.HealVerificationsAlreadyExistsTotal, + HealFinalizePublishesTotal: s.HealFinalizePublishesTotal, + HealFinalizeCleanupsTotal: cloneUint64Map(s.HealFinalizeCleanupsTotal), + SelfHealingPendingClaims: s.SelfHealingPendingClaims, + SelfHealingStagingBytes: s.SelfHealingStagingBytes, + RecheckCandidatesFoundTotal: s.RecheckCandidatesFoundTotal, + RecheckEvidenceSubmittedTotal: cloneUint64Map(s.RecheckEvidenceSubmittedTotal), + RecheckEvidenceAlreadySubmittedTotal: s.RecheckEvidenceAlreadySubmittedTotal, + RecheckExecutionFailuresTotal: cloneUint64Map(s.RecheckExecutionFailuresTotal), + RecheckPendingCandidates: s.RecheckPendingCandidates, + } +} + +func cloneUint64Map(in map[string]uint64) map[string]uint64 { + if len(in) == 0 { + return nil + } + out := make(map[string]uint64, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + // GetStatus returns the current system status including optional P2P info func (s *SupernodeStatusService) GetStatus(ctx context.Context, includeP2PMetrics bool) (*pb.StatusResponse, error) { fields := logtrace.Fields{logtrace.FieldMethod: "GetStatus", logtrace.FieldModule: "SupernodeStatusService"} @@ -130,6 +167,11 @@ func (s *SupernodeStatusService) GetStatus(ctx context.Context, includeP2PMetric } } + // LEP-6 metrics are cheap in-memory counters/gauges. Include them on every + // status response so operators can inspect storage-truth runtime state through + // the existing status endpoint instead of a LEP-6-only metrics endpoint. + resp.Lep6Metrics = lep6StatusMetrics(lep6metrics.Snapshot()) + if includeP2PMetrics && s.p2pService != nil { // Prepare optional P2P metrics container (only when requested). pm := &pb.StatusResponse_P2PMetrics{ diff --git a/supernode/status/service_test.go b/supernode/status/service_test.go index c1950df8..217e38b2 100644 --- a/supernode/status/service_test.go +++ b/supernode/status/service_test.go @@ -3,6 +3,7 @@ package status import ( "testing" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" "github.com/LumeraProtocol/supernode/v2/supernode/config" ) @@ -20,3 +21,38 @@ func TestNewSupernodeStatusService_StoragePathsUsesBaseDir(t *testing.T) { t.Fatalf("unexpected storagePaths: %#v", svc.storagePaths) } } + +func TestStatusResponse_ExposesLEP6MetricsSnapshot(t *testing.T) { + lep6metrics.Reset() + lep6metrics.IncDispatchResult("PASS") + lep6metrics.IncHealClaim("submitted") + lep6metrics.IncHealVerification("submitted", true) + lep6metrics.IncRecheckSubmission("RECHECK_CONFIRMED_FAIL", "submitted") + lep6metrics.SetSelfHealingPendingClaims(2) + t.Cleanup(lep6metrics.Reset) + + svc := NewSupernodeStatusService(nil, nil, nil, nil) + resp, err := svc.GetStatus(t.Context(), false) + if err != nil { + t.Fatalf("GetStatus() error = %v", err) + } + if resp.GetLep6Metrics() == nil { + t.Fatal("GetStatus() did not include LEP-6 metrics snapshot") + } + lep6 := resp.GetLep6Metrics() + if got := lep6.GetDispatchResultsTotal()["pass"]; got != 1 { + t.Fatalf("dispatch pass counter = %d, want 1 (all=%#v)", got, lep6.GetDispatchResultsTotal()) + } + if got := lep6.GetHealClaimsSubmittedTotal()["submitted"]; got != 1 { + t.Fatalf("heal claim submitted counter = %d, want 1", got) + } + if got := lep6.GetHealVerificationsSubmittedTotal()["verified=positive,result=submitted"]; got != 1 { + t.Fatalf("heal verification submitted counter = %d, want 1", got) + } + if got := lep6.GetRecheckEvidenceSubmittedTotal()["class=recheck_confirmed_fail,outcome=submitted"]; got != 1 { + t.Fatalf("recheck evidence submitted counter = %d, want 1", got) + } + if got := lep6.GetSelfHealingPendingClaims(); got != 2 { + t.Fatalf("self-healing pending claims = %d, want 2", got) + } +} diff --git a/supernode/storage_challenge/lep6_client_factory.go b/supernode/storage_challenge/lep6_client_factory.go new file mode 100644 index 00000000..12f30521 --- /dev/null +++ b/supernode/storage_challenge/lep6_client_factory.go @@ -0,0 +1,115 @@ +package storage_challenge + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" + "sync" + + "github.com/LumeraProtocol/lumera/x/lumeraid/securekeyx" + "github.com/LumeraProtocol/supernode/v2/gen/supernode" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + "github.com/LumeraProtocol/supernode/v2/pkg/net/credentials" + grpcclient "github.com/LumeraProtocol/supernode/v2/pkg/net/grpc/client" + "github.com/LumeraProtocol/supernode/v2/pkg/netutil" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + "google.golang.org/grpc" +) + +// secureSupernodeClientFactory dials peer supernodes using the same secure +// gRPC stack the legacy storage_challenge loop uses (see +// service.go::callGetSliceProof). It is the production implementation of +// SupernodeClientFactory wired by supernode/cmd/start.go. +type secureSupernodeClientFactory struct { + lumera lumera.Client + kr keyring.Keyring + self string + defaultPort uint16 + + mu sync.Mutex + grpcClient *grpcclient.Client + grpcOpts *grpcclient.ClientOptions +} + +// NewSecureSupernodeClientFactory builds a SupernodeClientFactory backed by +// the secure key-exchange gRPC stack. self is the local identity used in the +// ALTS handshake; defaultPort is the supernode port to fall back to when the +// chain-registered LatestAddress contains only a host. +func NewSecureSupernodeClientFactory(client lumera.Client, kr keyring.Keyring, self string, defaultPort uint16) SupernodeClientFactory { + return &secureSupernodeClientFactory{ + lumera: client, + kr: kr, + self: strings.TrimSpace(self), + defaultPort: defaultPort, + } +} + +func (f *secureSupernodeClientFactory) ensureClient() error { + f.mu.Lock() + defer f.mu.Unlock() + + if f.grpcClient != nil { + return nil + } + validator := lumera.NewSecureKeyExchangeValidator(f.lumera) + creds, err := credentials.NewClientCreds(&credentials.ClientOptions{ + CommonOptions: credentials.CommonOptions{ + Keyring: f.kr, + LocalIdentity: f.self, + PeerType: securekeyx.Supernode, + Validator: validator, + }, + }) + if err != nil { + return fmt.Errorf("create secure gRPC client creds: %w", err) + } + f.grpcClient = grpcclient.NewClient(creds) + f.grpcOpts = grpcclient.DefaultClientOptions() + f.grpcOpts.EnableRetries = true + return nil +} + +// 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) { + if err := f.ensureClient(); err != nil { + return nil, err + } + info, err := f.lumera.SuperNode().GetSupernodeWithLatestAddress(ctx, target) + if err != nil || info == nil { + return nil, fmt.Errorf("resolve target %q: %w", target, err) + } + raw := strings.TrimSpace(info.LatestAddress) + if raw == "" { + return nil, fmt.Errorf("no address for target %q", target) + } + 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) + } + 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) + if err != nil { + return nil, fmt.Errorf("dial target %q: %w", target, err) + } + return &secureCompoundClient{conn: conn, client: supernode.NewStorageChallengeServiceClient(conn)}, nil +} + +type secureCompoundClient struct { + conn *grpc.ClientConn + client supernode.StorageChallengeServiceClient +} + +func (c *secureCompoundClient) GetCompoundProof(ctx context.Context, req *supernode.GetCompoundProofRequest) (*supernode.GetCompoundProofResponse, error) { + return c.client.GetCompoundProof(ctx, req) +} + +func (c *secureCompoundClient) Close() error { + if c == nil || c.conn == nil { + return nil + } + return c.conn.Close() +} diff --git a/supernode/storage_challenge/lep6_dispatch.go b/supernode/storage_challenge/lep6_dispatch.go new file mode 100644 index 00000000..328e6872 --- /dev/null +++ b/supernode/storage_challenge/lep6_dispatch.go @@ -0,0 +1,684 @@ +package storage_challenge + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/gen/supernode" + snkeyring "github.com/LumeraProtocol/supernode/v2/pkg/keyring" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" + "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge" + "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge/deterministic" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + "lukechampine.com/blake3" +) + +// LEP6 dispatcher — challenger-side per-epoch loop for the LEP-6 compound +// storage challenge. See docs/plans/LEP6_SUPERNODE_IMPLEMENTATION_PLAN_v2.md +// §2.3 (PR3) for full design rationale and §9-§11 of LEP6.md for the +// deterministic protocol surfaces. +// +// PR3 scope: +// - Reads EpochAnchor + assigned targets + audit Params (mode gate + +// bucket thresholds + multi-range params). +// - For each (target, bucket ∈ {RECENT, OLD}) deterministically selects +// ticket / artifact / ordinal / ranges. +// - Issues GetCompoundProof to the target via SupernodeClientFactory. +// - Locally recomputes the BLAKE3 proof hash, classifies PASS/FAIL, +// signs the transcript, and appends the StorageProofResult to the +// buffer for the host reporter to drain. +// +// PR3 does NOT cover: +// - Observer attestation collection (post-LEP-6 work). +// - RECHECK bucket dispatch (PR5 recheck service). +// - Probation/heal-op exclusion semantics. +// +// Ticket discovery is delegated to a TicketProvider interface. Production +// startup wires ChainTicketProvider, backed by x/action ListActionsBySuperNode; +// NoTicketProvider is retained only for tests and defensive fallback. + +// SupernodeCompoundClient is the minimal RPC surface the dispatcher needs +// to drive a target's recipient handler. The real implementation wraps the +// secure gRPC stub (gen/supernode.StorageChallengeServiceClient); tests +// inject a stub directly. +type SupernodeCompoundClient interface { + GetCompoundProof(ctx context.Context, req *supernode.GetCompoundProofRequest) (*supernode.GetCompoundProofResponse, error) + Close() error +} + +// SupernodeClientFactory dials a target supernode and returns a compound- +// proof client. Implementations should reuse the existing supernode-to- +// supernode secure gRPC dialer (see service.go::callGetSliceProof for the +// reference implementation). +type SupernodeClientFactory interface { + Dial(ctx context.Context, targetSupernodeAccount string) (SupernodeCompoundClient, error) +} + +// CascadeMetaProvider returns the cascade metadata for a ticket. The +// resolver in pkg/storagechallenge/lep6_resolution.go consumes the result +// to derive (artifact_count, artifact_key) without round-tripping to the +// chain on the hot path. +type CascadeMetaProvider interface { + GetCascadeMetadata(ctx context.Context, ticketID string) (*actiontypes.CascadeMetadata, uint64, error) +} + +// TicketProvider enumerates the cascade tickets that the given target +// supernode is a participant on. Returns the action_id and the action's +// register-time block height (for ClassifyTicketBucket). +type TicketProvider interface { + TicketsForTarget(ctx context.Context, targetSupernodeAccount string) ([]TicketDescriptor, error) +} + +// TicketDescriptor is a minimal projection of a cascade action that the +// dispatcher needs for bucket classification. +type TicketDescriptor struct { + TicketID string + AnchorBlock int64 +} + +// NoTicketProvider always reports zero tickets. It is used by tests and as a +// defensive fallback only; production startup wires ChainTicketProvider. +type NoTicketProvider struct{} + +// TicketsForTarget always returns nil, nil. +func (NoTicketProvider) TicketsForTarget(_ context.Context, _ string) ([]TicketDescriptor, error) { + return nil, nil +} + +// LEP6Dispatcher is the per-epoch challenger loop. Construct via +// NewLEP6Dispatcher and invoke DispatchEpoch from the storage_challenge +// Service tick. +type LEP6Dispatcher struct { + client lumera.Client + keyring keyring.Keyring + keyName string + self string + supernodeClient SupernodeClientFactory + tickets TicketProvider + meta CascadeMetaProvider + buffer *Buffer + mu sync.Mutex +} + +// NewLEP6Dispatcher constructs a dispatcher. supernodeClient, tickets, +// meta, and buffer are required; passing nil for any of them returns an +// error. +func NewLEP6Dispatcher( + client lumera.Client, + kr keyring.Keyring, + keyName, self string, + supernodeClient SupernodeClientFactory, + tickets TicketProvider, + meta CascadeMetaProvider, + buffer *Buffer, +) (*LEP6Dispatcher, error) { + if client == nil || client.Audit() == nil { + return nil, fmt.Errorf("lep6 dispatcher: lumera client missing audit module") + } + if kr == nil { + return nil, fmt.Errorf("lep6 dispatcher: keyring is nil") + } + if strings.TrimSpace(keyName) == "" { + return nil, fmt.Errorf("lep6 dispatcher: key name is empty") + } + if strings.TrimSpace(self) == "" { + return nil, fmt.Errorf("lep6 dispatcher: self identity is empty") + } + if supernodeClient == nil { + return nil, fmt.Errorf("lep6 dispatcher: supernode client factory is nil") + } + if tickets == nil { + tickets = NoTicketProvider{} + } + if meta == nil { + return nil, fmt.Errorf("lep6 dispatcher: cascade meta provider is nil") + } + if buffer == nil { + return nil, fmt.Errorf("lep6 dispatcher: result buffer is nil") + } + return &LEP6Dispatcher{ + client: client, + keyring: kr, + keyName: keyName, + self: self, + supernodeClient: supernodeClient, + tickets: tickets, + meta: meta, + buffer: buffer, + }, nil +} + +// DispatchEpoch runs the challenger flow for epochID. The flow gates on +// StorageTruthEnforcementMode: UNSPECIFIED skips dispatch entirely; +// SHADOW/SOFT/FULL all execute the same off-chain path (chain enforces +// mode-specific side-effects). +// +// Returns nil if the dispatch was skipped (no error), and any error that +// prevents the loop from running at all (e.g., chain queries fail). +// Per-target failures are surfaced as StorageProofResult{ResultClass=FAIL} +// rather than returning an error. +func (d *LEP6Dispatcher) DispatchEpoch(ctx context.Context, epochID uint64) error { + started := time.Now() + defer func() { lep6metrics.ObserveDispatchEpochDuration("challenger", time.Since(started)) }() + + paramsResp, err := d.client.Audit().GetParams(ctx) + if err != nil { + return fmt.Errorf("lep6 dispatch: get params: %w", err) + } + if paramsResp == nil { + return fmt.Errorf("lep6 dispatch: get params returned nil response") + } + params := paramsResp.Params + mode := params.StorageTruthEnforcementMode + + if mode == audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED { + logtrace.Debug(ctx, "lep6 dispatch: enforcement mode UNSPECIFIED; skipping", logtrace.Fields{ + "epoch_id": epochID, + }) + return nil + } + + anchorResp, err := d.client.Audit().GetEpochAnchor(ctx, epochID) + if err != nil || anchorResp == nil || anchorResp.Anchor.EpochId != epochID { + return fmt.Errorf("lep6 dispatch: epoch anchor not yet available for epoch %d", epochID) + } + anchor := anchorResp.Anchor + + assigned, err := d.client.Audit().GetAssignedTargets(ctx, d.self, epochID) + if err != nil || assigned == nil { + return fmt.Errorf("lep6 dispatch: get assigned targets: %w", err) + } + targets := assigned.TargetSupernodeAccounts + if len(targets) == 0 { + logtrace.Debug(ctx, "lep6 dispatch: no targets assigned this epoch", logtrace.Fields{ + "epoch_id": epochID, + "mode": mode.String(), + }) + return nil + } + + // Best-effort current height for bucket classification; if it fails + // we still run, falling through to UNSPECIFIED bucket = no eligible. + currentHeight := int64(anchor.EpochEndHeight) + if currentHeight == 0 { + if blk, blkErr := d.client.Node().GetLatestBlock(ctx); blkErr == nil && blk != nil { + if sdk := blk.GetSdkBlock(); sdk != nil { + currentHeight = sdk.Header.Height + } else if b := blk.GetBlock(); b != nil { + currentHeight = b.Header.Height + } + } + } + + logtrace.Info(ctx, "lep6 dispatch: starting epoch", logtrace.Fields{ + "epoch_id": epochID, + "mode": mode.String(), + "targets": len(targets), + }) + + d.mu.Lock() + defer d.mu.Unlock() + + for _, target := range targets { + target = strings.TrimSpace(target) + if target == "" || target == d.self { + continue + } + if err := d.dispatchTarget(ctx, epochID, anchor, params, currentHeight, target); err != nil { + logtrace.Warn(ctx, "lep6 dispatch: target loop error", logtrace.Fields{ + "epoch_id": epochID, + "target": target, + "error": err.Error(), + }) + } + } + return nil +} + +func (d *LEP6Dispatcher) dispatchTarget( + ctx context.Context, + epochID uint64, + anchor audittypes.EpochAnchor, + params audittypes.Params, + currentHeight int64, + target string, +) error { + tickets, err := d.tickets.TicketsForTarget(ctx, target) + 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(), + }) + tickets = nil + } + + for _, bucket := range []audittypes.StorageProofBucketType{ + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD, + } { + eligibleIDs := make([]string, 0, len(tickets)) + for _, t := range tickets { + cls := deterministic.ClassifyTicketBucket(currentHeight, t.AnchorBlock, + params.StorageTruthRecentBucketMaxBlocks, params.StorageTruthOldBucketMinBlocks) + if cls == bucket { + eligibleIDs = append(eligibleIDs, t.TicketID) + } + } + + if len(eligibleIDs) == 0 { + lep6metrics.SetNoTicketProviderActive(true) + d.appendNoEligible(ctx, d.buffer, epochID, anchor, target, bucket, "") + continue + } + + ticketID := deterministic.SelectTicketForBucket(eligibleIDs, nil, anchor.Seed, target, bucket) + if ticketID == "" { + lep6metrics.SetNoTicketProviderActive(true) + d.appendNoEligible(ctx, d.buffer, epochID, anchor, target, bucket, "") + continue + } + + if err := d.dispatchTicket(ctx, d.buffer, epochID, anchor, params, 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(), + }) + } + } + return nil +} + +// appendNoEligible emits a NO_ELIGIBLE_TICKET row for (target, bucket). +// +// LEP-6 review (Matee, 2026-05-06): +// - L5: when the no-eligible was triggered AFTER selecting a ticket (e.g. +// class-roll landed on an empty class), the caller passes the selected +// ticket id via selectedTicketIDForLog so it surfaces in structured logs. +// The chain row itself MUST keep ticket_id="" — the chain validator at +// msg_submit_epoch_report_storage_proofs.go:92-94 rejects NO_ELIGIBLE +// rows that carry a ticket_id. +// - H4: a sign failure must NOT silently emit an empty/garbage signature +// (chain rejects empty challenger_signature, validator at :117-118). +// Drop the row, increment metric, log structured. Other rows in the +// same epoch are unaffected. +func (d *LEP6Dispatcher) appendNoEligible( + ctx context.Context, + buf *Buffer, + epochID uint64, + anchor audittypes.EpochAnchor, + target string, + bucket audittypes.StorageProofBucketType, + selectedTicketIDForLog string, +) { + // Wave 2 / F-PR286-02: Lumera chain validateNoEligibleTicketConsistency + // rejects NO_ELIGIBLE_TICKET when a recent eligible transcript exists for + // the same (target,bucket) within its consistency window. The current + // supernode audit.Module does not expose that chain history query, so this + // guard covers the safe local subset: never emit NO_ELIGIBLE when this + // process already buffered an eligible row for the same epoch/target/bucket. + // Skipping is safer than poisoning the whole report; in FULL mode Wave 1 + // coverage checks will abort submission. A selectedTicketIDForLog alone is + // not enough to suppress: H6 class-roll fallback intentionally emits + // NO_ELIGIBLE when the selected ticket has no rolled concrete class. + if buf != nil && buf.HasEligibleResult(epochID, target, bucket) { + lep6metrics.IncDispatchInternalFailure("no_eligible_consistency_suppressed") + logtrace.Warn(ctx, "lep6 dispatch: suppressed no-eligible row due to eligible-ticket consistency", logtrace.Fields{ + "epoch_id": epochID, + "target": target, + "bucket": bucket.String(), + "selected_ticket": selectedTicketIDForLog, + }) + return + } + + transcriptHashHex, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ + EpochID: epochID, + ChallengerSupernodeAccount: d.self, + TargetSupernodeAccount: target, + TicketID: "", + Bucket: bucket, + ArtifactClass: audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, + }) + if err != nil { + logtrace.Warn(ctx, "lep6 dispatch: no-eligible transcript hash error", logtrace.Fields{ + "epoch_id": epochID, "target": target, "selected_ticket": selectedTicketIDForLog, "error": err.Error(), + }) + return + } + sig, signErr := snkeyring.SignBytes(d.keyring, d.keyName, []byte(transcriptHashHex)) + if signErr != nil { + lep6metrics.IncDispatchSignFailure("no_eligible") + logtrace.Warn(ctx, "lep6 dispatch: no-eligible sign error — row dropped", logtrace.Fields{ + "epoch_id": epochID, "target": target, "bucket": bucket.String(), "selected_ticket": selectedTicketIDForLog, "error": signErr.Error(), + }) + return + } + + if selectedTicketIDForLog != "" { + logtrace.Info(ctx, "lep6 dispatch: no-eligible after class roll", logtrace.Fields{ + "epoch_id": epochID, "target": target, "bucket": bucket.String(), "selected_ticket": selectedTicketIDForLog, + }) + } + + lep6metrics.IncDispatchResult(audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET.String()) + buf.Append(epochID, &audittypes.StorageProofResult{ + TargetSupernodeAccount: target, + ChallengerSupernodeAccount: d.self, + BucketType: bucket, + ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET, + TranscriptHash: transcriptHashHex, + ChallengerSignature: hex.EncodeToString(sig), + Details: "no eligible ticket for bucket", + }) + _ = anchor +} + +// dispatchTicket runs the per-ticket challenge flow. +// +// LEP-6 review H3 (Matee, 2026-05-06) — partial application with chain-anchored +// reasoning: the plan's blanket "convert every early-return to appendFail" is +// not safe at pre-derivation sites. Chain validator +// (lumera@v1.12.0 x/audit/v1/keeper/msg_submit_epoch_report_storage_proofs.go:114-128) +// requires non-NO_ELIGIBLE rows to carry a valid ArtifactKey, +// DerivationInputHash, and ChallengerSignature, plus an INDEX/SYMBOL class +// with ordinal < anchored count. At sites where we fail BEFORE deriving +// those (meta fetch, ordinal selection, key resolution, size resolution, +// offset compute, deriv-hash compute, transcript hash), we cannot construct +// a chain-acceptable row — synthesizing one with empty/zero fields would +// poison the entire epoch report (validator rejects the message). At sites +// where we fail AFTER deriving them (dial, GetCompoundProof, range-count, +// range-size, proof-hash mismatch), we already emit appendFail with +// INVALID_TRANSCRIPT/HASH_MISMATCH/TIMEOUT — that part is unchanged. +// +// What this method does add: +// - Distinguish ctx.Err() so caller cancellation propagates cleanly. +// - Bump a per-stage internal-failure metric so operators can monitor +// pre-derivation gaps without having to grep logs. +// - Tighter structured logging (stage label) at every early return. +func (d *LEP6Dispatcher) dispatchTicket( + ctx context.Context, + buf *Buffer, + epochID uint64, + anchor audittypes.EpochAnchor, + params audittypes.Params, + target string, + bucket audittypes.StorageProofBucketType, + ticketID string, +) error { + meta, fileSizeKbs, err := d.meta.GetCascadeMetadata(ctx, ticketID) + if err != nil || meta == nil { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + lep6metrics.IncDispatchInternalFailure("cascade_meta") + return fmt.Errorf("get cascade meta: %w", err) + } + + 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) + 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) + return nil + } + + var artifactCount uint32 + switch class { + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: + artifactCount = indexCount + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: + artifactCount = symbolCount + } + ordinal, err := deterministic.SelectArtifactOrdinal(anchor.Seed, target, ticketID, class, artifactCount) + if err != nil { + lep6metrics.IncDispatchInternalFailure("select_ordinal") + return fmt.Errorf("select ordinal: %w", err) + } + artifactKey, err := storagechallenge.ResolveArtifactKey(meta, class, ordinal) + if err != nil { + lep6metrics.IncDispatchInternalFailure("resolve_key") + return fmt.Errorf("resolve artifact key: %w", err) + } + artifactSize, err := storagechallenge.ResolveArtifactSize(&actiontypes.Action{FileSizeKbs: int64(fileSizeKbs)}, meta, class, ordinal) + if err != nil { + lep6metrics.IncDispatchInternalFailure("resolve_size") + return fmt.Errorf("resolve artifact size: %w", err) + } + + rangeLen := uint64(params.StorageTruthCompoundRangeLenBytes) + if rangeLen == 0 { + rangeLen = uint64(deterministic.LEP6CompoundRangeLenBytes) + } + k := int(params.StorageTruthCompoundRangesPerArtifact) + if k == 0 { + k = deterministic.LEP6CompoundRangesPerArtifact + } + + offsets, err := deterministic.ComputeMultiRangeOffsets(anchor.Seed, target, ticketID, class, ordinal, artifactSize, rangeLen, k) + if err != nil { + lep6metrics.IncDispatchInternalFailure("compute_offsets") + return fmt.Errorf("compute offsets: %w", err) + } + ranges := make([]*supernode.ByteRange, len(offsets)) + for i, off := range offsets { + ranges[i] = &supernode.ByteRange{Start: off, End: off + rangeLen} + } + + derivHash, err := deterministic.DerivationInputHash(anchor.Seed, target, 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) + + req := &supernode.GetCompoundProofRequest{ + ChallengeId: challengeID, + EpochId: epochID, + Seed: anchor.Seed, + TicketId: ticketID, + TargetSupernodeAccount: target, + ChallengerAccount: d.self, + ArtifactClass: uint32(class), + ArtifactOrdinal: ordinal, + ArtifactCount: artifactCount, + BucketType: uint32(bucket), + ArtifactKey: artifactKey, + ArtifactSize: artifactSize, + Ranges: ranges, + } + + conn, err := d.supernodeClient.Dial(ctx, target) + if err != nil { + d.appendFail(ctx, buf, epochID, target, bucket, ticketID, class, ordinal, artifactCount, artifactKey, derivHash, classifyProofFailure(err, "dial"), fmt.Sprintf("dial: %v", err)) + return nil + } + defer func() { _ = conn.Close() }() + + resp, err := conn.GetCompoundProof(ctx, req) + if err != nil || resp == nil || !resp.Ok { + reason := "no response" + if err != nil { + reason = err.Error() + } 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) + 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)) + 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)) + 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)) + return nil + } + + transcriptHashHex, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ + EpochID: epochID, + ChallengerSupernodeAccount: d.self, + TargetSupernodeAccount: target, + TicketID: ticketID, + Bucket: bucket, + ArtifactClass: class, + ArtifactOrdinal: ordinal, + ArtifactKey: artifactKey, + DerivationInputHash: derivHash, + CompoundProofHashHex: gotHash, + }) + if err != nil { + lep6metrics.IncDispatchInternalFailure("transcript_hash") + return fmt.Errorf("transcript hash: %w", err) + } + sig, signErr := snkeyring.SignBytes(d.keyring, d.keyName, []byte(transcriptHashHex)) + if signErr != nil { + // LEP-6 review H4: drop the row instead of emitting empty signature. + lep6metrics.IncDispatchSignFailure("PASS") + logtrace.Warn(ctx, "lep6 dispatch: pass-row sign error — row dropped", logtrace.Fields{ + "epoch_id": epochID, "target": target, "ticket": ticketID, "error": signErr.Error(), + }) + return nil + } + + lep6metrics.IncDispatchResult(audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS.String()) + buf.Append(epochID, &audittypes.StorageProofResult{ + TargetSupernodeAccount: target, + ChallengerSupernodeAccount: d.self, + TicketId: ticketID, + BucketType: bucket, + ArtifactClass: class, + ArtifactOrdinal: ordinal, + ArtifactKey: artifactKey, + ArtifactCount: artifactCount, + ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + TranscriptHash: transcriptHashHex, + DerivationInputHash: derivHash, + ChallengerSignature: hex.EncodeToString(sig), + }) + return nil +} + +// appendFail emits a FAIL/HASH_MISMATCH/INVALID_TRANSCRIPT/TIMEOUT row. +// +// LEP-6 review H4 (Matee, 2026-05-06): if the transcript signing call fails, +// drop this row, increment the sign-failure metric, and log structured. Do +// NOT emit a row with an empty ChallengerSignature — the chain validator at +// msg_submit_epoch_report_storage_proofs.go:117-118 rejects empty signatures +// and rejects the entire epoch report; one keyring transient would otherwise +// poison every row in the same epoch. Other targets/buckets in the same +// dispatch loop continue to be processed. +func (d *LEP6Dispatcher) appendFail( + ctx context.Context, + buf *Buffer, + epochID uint64, + target string, + bucket audittypes.StorageProofBucketType, + ticketID string, + class audittypes.StorageProofArtifactClass, + ordinal uint32, + artifactCount uint32, + artifactKey string, + derivHash string, + resultClass audittypes.StorageProofResultClass, + reason string, +) { + transcriptHashHex, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ + EpochID: epochID, + ChallengerSupernodeAccount: d.self, + TargetSupernodeAccount: target, + TicketID: ticketID, + Bucket: bucket, + ArtifactClass: class, + ArtifactOrdinal: ordinal, + ArtifactKey: artifactKey, + DerivationInputHash: derivHash, + // CompoundProofHashHex empty on failure — captures the non-pass shape. + }) + if err != nil { + logtrace.Warn(ctx, "lep6 dispatch: fail transcript hash error", logtrace.Fields{ + "epoch_id": epochID, "target": target, "ticket": ticketID, "error": err.Error(), + }) + return + } + sig, signErr := snkeyring.SignBytes(d.keyring, d.keyName, []byte(transcriptHashHex)) + if signErr != nil { + lep6metrics.IncDispatchSignFailure(resultClass.String()) + logtrace.Warn(ctx, "lep6 dispatch: fail row sign error — row dropped", logtrace.Fields{ + "epoch_id": epochID, "target": target, "ticket": ticketID, "result_class": resultClass.String(), "error": signErr.Error(), + }) + return + } + + lep6metrics.IncDispatchResult(resultClass.String()) + buf.Append(epochID, &audittypes.StorageProofResult{ + TargetSupernodeAccount: target, + ChallengerSupernodeAccount: d.self, + TicketId: ticketID, + BucketType: bucket, + ArtifactClass: class, + ArtifactOrdinal: ordinal, + ArtifactKey: artifactKey, + ArtifactCount: artifactCount, + ResultClass: resultClass, + TranscriptHash: transcriptHashHex, + DerivationInputHash: derivHash, + ChallengerSignature: hex.EncodeToString(sig), + Details: reason, + }) +} + +func deriveCompoundChallengeID(seed []byte, epochID uint64, target, ticketID string, class audittypes.StorageProofArtifactClass, ordinal uint32) string { + h := blake3.New(32, nil) + _, _ = h.Write(seed) + _, _ = h.Write([]byte(fmt.Sprintf("lep6:%d:%s:%s:%d:%d", epochID, target, ticketID, int32(class), ordinal))) + return hex.EncodeToString(h.Sum(nil)) +} + +func classifyProofFailure(err error, reason string) audittypes.StorageProofResultClass { + if err == nil { + lower := strings.ToLower(strings.TrimSpace(reason)) + if lower == "" || strings.Contains(lower, "timeout") || strings.Contains(lower, "no response") { + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE + } + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE + } + if st, ok := status.FromError(err); ok { + switch st.Code() { + case codes.DeadlineExceeded, codes.Canceled, codes.Unavailable: + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE + } + } + return audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT +} diff --git a/supernode/storage_challenge/lep6_dispatch_test.go b/supernode/storage_challenge/lep6_dispatch_test.go new file mode 100644 index 00000000..6c967dd7 --- /dev/null +++ b/supernode/storage_challenge/lep6_dispatch_test.go @@ -0,0 +1,525 @@ +package storage_challenge + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "testing" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + supernodepb "github.com/LumeraProtocol/supernode/v2/gen/supernode" + lumeraMock "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + auditmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/audit" + nodemod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/node" + "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge/deterministic" + "github.com/LumeraProtocol/supernode/v2/supernode/recheck" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + "github.com/cosmos/cosmos-sdk/crypto/hd" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + "github.com/cosmos/cosmos-sdk/types/query" + "github.com/cosmos/go-bip39" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "lukechampine.com/blake3" +) + +// dispatchAuditModule is an in-test stub of audit.Module used to drive +// LEP6Dispatcher per-test; mirrors the host_reporter test pattern. +type dispatchAuditModule struct { + params *audittypes.QueryParamsResponse + anchor *audittypes.QueryEpochAnchorResponse + assigned *audittypes.QueryAssignedTargetsResponse + getParamsHook func() +} + +var _ auditmod.Module = (*dispatchAuditModule)(nil) + +func (s *dispatchAuditModule) GetParams(ctx context.Context) (*audittypes.QueryParamsResponse, error) { + if s.getParamsHook != nil { + s.getParamsHook() + } + return s.params, nil +} +func (s *dispatchAuditModule) GetEpochAnchor(ctx context.Context, epochID uint64) (*audittypes.QueryEpochAnchorResponse, error) { + return s.anchor, nil +} +func (s *dispatchAuditModule) GetCurrentEpoch(ctx context.Context) (*audittypes.QueryCurrentEpochResponse, error) { + return &audittypes.QueryCurrentEpochResponse{}, nil +} +func (s *dispatchAuditModule) GetCurrentEpochAnchor(ctx context.Context) (*audittypes.QueryCurrentEpochAnchorResponse, error) { + return &audittypes.QueryCurrentEpochAnchorResponse{}, nil +} +func (s *dispatchAuditModule) GetAssignedTargets(ctx context.Context, supernodeAccount string, epochID uint64) (*audittypes.QueryAssignedTargetsResponse, error) { + return s.assigned, nil +} +func (s *dispatchAuditModule) GetEpochReport(ctx context.Context, epochID uint64, supernodeAccount string) (*audittypes.QueryEpochReportResponse, error) { + return &audittypes.QueryEpochReportResponse{}, nil +} +func (s *dispatchAuditModule) GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*audittypes.QueryEpochReportsByReporterResponse, error) { + return &audittypes.QueryEpochReportsByReporterResponse{}, nil +} +func (s *dispatchAuditModule) GetNodeSuspicionState(ctx context.Context, supernodeAccount string) (*audittypes.QueryNodeSuspicionStateResponse, error) { + return &audittypes.QueryNodeSuspicionStateResponse{}, nil +} +func (s *dispatchAuditModule) GetReporterReliabilityState(ctx context.Context, reporterAccount string) (*audittypes.QueryReporterReliabilityStateResponse, error) { + return &audittypes.QueryReporterReliabilityStateResponse{}, nil +} +func (s *dispatchAuditModule) GetTicketDeteriorationState(ctx context.Context, ticketID string) (*audittypes.QueryTicketDeteriorationStateResponse, error) { + return &audittypes.QueryTicketDeteriorationStateResponse{}, nil +} +func (s *dispatchAuditModule) GetHealOp(ctx context.Context, healOpID uint64) (*audittypes.QueryHealOpResponse, error) { + return &audittypes.QueryHealOpResponse{}, nil +} +func (s *dispatchAuditModule) GetHealOpsByStatus(ctx context.Context, status audittypes.HealOpStatus, pagination *query.PageRequest) (*audittypes.QueryHealOpsByStatusResponse, error) { + return &audittypes.QueryHealOpsByStatusResponse{}, nil +} +func (s *dispatchAuditModule) GetHealOpsByTicket(ctx context.Context, ticketID string, pagination *query.PageRequest) (*audittypes.QueryHealOpsByTicketResponse, error) { + return &audittypes.QueryHealOpsByTicketResponse{}, nil +} + +// stubTicketProvider returns a fixed list per target. +type stubTicketProvider struct { + tickets map[string][]TicketDescriptor + err error +} + +func (s stubTicketProvider) TicketsForTarget(_ context.Context, target string) ([]TicketDescriptor, error) { + if s.err != nil { + return nil, s.err + } + return s.tickets[target], nil +} + +// stubMetaProvider returns a fixed cascade meta + size for any ticket. +type stubMetaProvider struct { + meta *actiontypes.CascadeMetadata + size uint64 + err error +} + +func (s stubMetaProvider) GetCascadeMetadata(_ context.Context, _ string) (*actiontypes.CascadeMetadata, uint64, error) { + if s.err != nil { + return nil, 0, s.err + } + return s.meta, s.size, nil +} + +// stubCompoundClient implements SupernodeCompoundClient. +type stubCompoundClient struct { + resp *supernodepb.GetCompoundProofResponse + err error +} + +func (s *stubCompoundClient) GetCompoundProof(_ context.Context, _ *supernodepb.GetCompoundProofRequest) (*supernodepb.GetCompoundProofResponse, error) { + return s.resp, s.err +} +func (s *stubCompoundClient) Close() error { return nil } + +// stubFactory always returns the same stubCompoundClient. +type stubFactory struct { + client *stubCompoundClient + err error +} + +func (s *stubFactory) Dial(_ context.Context, _ string) (SupernodeCompoundClient, error) { + if s.err != nil { + return nil, s.err + } + return s.client, nil +} + +func newDispatchKeyringAndIdentity(t *testing.T) (keyring.Keyring, string, string) { + t.Helper() + ir := codectypes.NewInterfaceRegistry() + cryptocodec.RegisterInterfaces(ir) + cdc := codec.NewProtoCodec(ir) + kr := keyring.NewInMemory(cdc) + entropy, err := bip39.NewEntropy(128) + require.NoError(t, err) + mnemonic, err := bip39.NewMnemonic(entropy) + require.NoError(t, err) + algos, _ := kr.SupportedAlgorithms() + algo, err := keyring.NewSigningAlgoFromString("secp256k1", algos) + require.NoError(t, err) + hdPath := hd.CreateHDPath(118, 0, 0).String() + rec, err := kr.NewAccount("dispatcher-test", mnemonic, "", hdPath, algo) + require.NoError(t, err) + addr, err := rec.GetAddress() + require.NoError(t, err) + return kr, "dispatcher-test", addr.String() +} + +// makeAnchor returns a deterministic EpochAnchor with a 32-byte seed +// derived from the epoch id so tests are reproducible across runs. +func makeAnchor(epochID uint64, endHeight int64, targets ...string) audittypes.EpochAnchor { + seed := sha256.Sum256([]byte("test-seed")) + return audittypes.EpochAnchor{ + EpochId: epochID, + EpochEndHeight: endHeight, + EpochLengthBlocks: 100, + Seed: seed[:], + ActiveSupernodeAccounts: append([]string{}, targets...), + TargetSupernodeAccounts: append([]string{}, targets...), + } +} + +// defaultParams returns audit Params with bucket thresholds matching the +// chain's defaults (3*EpochLengthBlocks RECENT, 30*EpochLengthBlocks OLD) +// and the requested enforcement mode. +func defaultParams(mode audittypes.StorageTruthEnforcementMode) audittypes.Params { + return audittypes.Params{ + StorageTruthEnforcementMode: mode, + StorageTruthRecentBucketMaxBlocks: 300, + StorageTruthOldBucketMinBlocks: 3000, + StorageTruthCompoundRangesPerArtifact: uint32(deterministic.LEP6CompoundRangesPerArtifact), + StorageTruthCompoundRangeLenBytes: uint32(deterministic.LEP6CompoundRangeLenBytes), + } +} + +// newDispatcher wires a dispatcher with the given audit module + factory + +// providers. Returns the dispatcher and the buffer (for assertions). +func newDispatcher( + t *testing.T, + audit *dispatchAuditModule, + factory SupernodeClientFactory, + tickets TicketProvider, + meta CascadeMetaProvider, +) (*LEP6Dispatcher, *Buffer) { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mockLumera := lumeraMock.NewMockClient(ctrl) + mockLumera.EXPECT().Audit().Return(audit).AnyTimes() + // Node() returns a typed-nil; only used when EpochAnchor.EpochEndHeight==0, + // which our tests always set non-zero, so this is unreachable in practice. + var nilNode nodemod.Module + mockLumera.EXPECT().Node().Return(nilNode).AnyTimes() + + kr, keyName, identity := newDispatchKeyringAndIdentity(t) + buf := NewBuffer() + d, err := NewLEP6Dispatcher(mockLumera, kr, keyName, identity, factory, tickets, meta, buf) + require.NoError(t, err) + return d, buf +} + +func TestAppendNoEligiblePreservedWhenOnlySelectedTicketExists(t *testing.T) { + audit := &dispatchAuditModule{} + 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") + + 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") + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET, results[0].ResultClass) +} + +func TestAppendNoEligibleSuppressedWhenBufferedEligibleResultExists(t *testing.T) { + audit := &dispatchAuditModule{} + d, buf := newDispatcher(t, audit, &stubFactory{}, NoTicketProvider{}, stubMetaProvider{}) + anchor := makeAnchor(10, 1000, "target-1") + bucket := audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD + buf.Append(10, &audittypes.StorageProofResult{ + TargetSupernodeAccount: "target-1", + BucketType: bucket, + ResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + }) + + d.appendNoEligible(context.Background(), buf, 10, anchor, "target-1", bucket, "") + + results := buf.CollectResults(10) + require.Len(t, results, 1) + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, results[0].ResultClass) +} + +func TestDispatchEpoch_ModeUnspecified_NoOp(t *testing.T) { + audit := &dispatchAuditModule{ + params: &audittypes.QueryParamsResponse{ + Params: defaultParams(audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED), + }, + } + d, buf := newDispatcher(t, audit, &stubFactory{}, NoTicketProvider{}, stubMetaProvider{}) + + require.NoError(t, d.DispatchEpoch(context.Background(), 7)) + require.Empty(t, buf.CollectResults(7), "buffer must be empty under UNSPECIFIED mode") +} + +func TestDispatchEpoch_GetParamsDoesNotHoldDispatcherLock(t *testing.T) { + var dispatcher *LEP6Dispatcher + var sawUnlocked bool + audit := &dispatchAuditModule{ + params: &audittypes.QueryParamsResponse{ + Params: defaultParams(audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED), + }, + } + audit.getParamsHook = func() { + if dispatcher.mu.TryLock() { + sawUnlocked = true + dispatcher.mu.Unlock() + } + } + dispatcher, _ = newDispatcher(t, audit, &stubFactory{}, NoTicketProvider{}, stubMetaProvider{}) + + require.NoError(t, dispatcher.DispatchEpoch(context.Background(), 7)) + require.True(t, sawUnlocked, "DispatchEpoch must not hold dispatcher mutex while querying params") +} + +func TestDispatchEpoch_GetParamsNilResponseIsClear(t *testing.T) { + audit := &dispatchAuditModule{} + dispatcher, _ := newDispatcher(t, audit, &stubFactory{}, NoTicketProvider{}, stubMetaProvider{}) + + err := dispatcher.DispatchEpoch(context.Background(), 7) + require.Error(t, err) + require.Contains(t, err.Error(), "lep6 dispatch: get params returned nil response") + require.NotContains(t, err.Error(), "") +} + +func TestDispatchEpoch_ModeShadow_AppendsResults(t *testing.T) { + const epochID uint64 = 11 + anchor := makeAnchor(epochID, 500, "sn-target") + 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"}}, + } + // NoTicketProvider → both buckets emit NO_ELIGIBLE_TICKET. + d, buf := newDispatcher(t, audit, &stubFactory{}, NoTicketProvider{}, stubMetaProvider{}) + + require.NoError(t, d.DispatchEpoch(context.Background(), epochID)) + results := buf.CollectResults(epochID) + require.Len(t, results, 2, "expected one NO_ELIGIBLE_TICKET per bucket") + for _, r := range results { + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET, r.ResultClass) + require.NotEmpty(t, r.TranscriptHash) + require.NotEmpty(t, r.ChallengerSignature) + } +} + +func TestDispatchEpoch_NoEligibleTicket_EmitsClass(t *testing.T) { + const epochID uint64 = 13 + // Anchor end-height=10000; tickets anchored at heights that fall in NEITHER + // bucket. Gap is delta ∈ (recent_max=300, old_min=3000), i.e. 301..2999. + // Pick anchor=8000 → currentHeight-anchor=2000 → UNSPECIFIED bucket. + anchor := makeAnchor(epochID, 10000, "sn-target") + 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"}}, + } + tickets := stubTicketProvider{tickets: map[string][]TicketDescriptor{ + "sn-target": {{TicketID: "tkt-gap", AnchorBlock: 8000}}, + }} + d, buf := newDispatcher(t, audit, &stubFactory{}, tickets, stubMetaProvider{}) + + require.NoError(t, d.DispatchEpoch(context.Background(), epochID)) + results := buf.CollectResults(epochID) + require.Len(t, results, 2) + for _, r := range results { + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET, r.ResultClass) + } +} + +// TestDispatchEpoch_GetCompoundProofError_EmitsFailClass exercises the dial / +// RPC failure path: when the ticket is eligible and the RPC returns an error, +// the dispatcher emits a FAIL-class result (not bubble the error up) so the +// chain still sees coverage. +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") + 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"}}, + } + tickets := stubTicketProvider{tickets: map[string][]TicketDescriptor{ + "sn-target": {{TicketID: "tkt-rpc-fail", AnchorBlock: 100}}, + }} + // Cascade meta: SYMBOL-only with one id; artifact_size big enough for 4*256. + meta := stubMetaProvider{ + meta: &actiontypes.CascadeMetadata{RqIdsIc: 0, RqIdsMax: 1, RqIdsIds: []string{"sym-0"}}, + size: 4 * 1024, + } + // Factory returns a client whose GetCompoundProof errors. + factory := &stubFactory{client: &stubCompoundClient{err: errors.New("rpc unavailable")}} + d, buf := newDispatcher(t, audit, factory, tickets, meta) + + require.NoError(t, d.DispatchEpoch(context.Background(), epochID)) + results := buf.CollectResults(epochID) + require.NotEmpty(t, results) + // Expect a FAIL class for the RECENT bucket (single eligible ticket) and + // NO_ELIGIBLE for OLD (empty there). + var sawFail, sawNoEligible bool + for _, r := range results { + switch r.ResultClass { + case audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT: + sawFail = true + require.Contains(t, r.Details, "rpc unavailable") + case audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET: + sawNoEligible = true + } + } + require.True(t, sawFail, "expected at least one FAIL class result on RPC error") + require.True(t, sawNoEligible, "expected NO_ELIGIBLE for the OLD bucket") +} + +func TestClassifyProofFailure_NonTimeoutRPCErrorsAreInvalidTranscript(t *testing.T) { + require.Equal(t, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, + classifyProofFailure(status.Error(codes.PermissionDenied, "not allowed"), "not allowed"), + ) + require.Equal(t, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, + classifyProofFailure(errors.New("connection refused"), "connection refused"), + ) + require.Equal(t, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, + classifyProofFailure(nil, "recipient validation failed"), + ) +} + +func TestClassifyProofFailure_TimeoutsRemainTimeoutOrNoResponse(t *testing.T) { + require.Equal(t, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE, + classifyProofFailure(context.DeadlineExceeded, "deadline exceeded"), + ) + require.Equal(t, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE, + classifyProofFailure(status.Error(codes.Unavailable, "unavailable"), "unavailable"), + ) + require.Equal(t, + audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE, + classifyProofFailure(nil, "no response"), + ) +} + +func TestDispatchEpoch_GetCompoundProofTimeout_EmitsTimeoutClass(t *testing.T) { + const epochID uint64 = 18 + anchor := makeAnchor(epochID, 200, "sn-target") + 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"}}, + } + tickets := stubTicketProvider{tickets: map[string][]TicketDescriptor{ + "sn-target": {{TicketID: "tkt-symbol", AnchorBlock: 100}}, + }} + meta := stubMetaProvider{ + meta: &actiontypes.CascadeMetadata{RqIdsIc: 0, RqIdsMax: 1, RqIdsIds: []string{"sym-0"}}, + size: 4 * 1024, + } + factory := &stubFactory{client: &stubCompoundClient{err: context.DeadlineExceeded}} + d, buf := newDispatcher(t, audit, factory, tickets, meta) + + require.NoError(t, d.DispatchEpoch(context.Background(), epochID)) + results := buf.CollectResults(epochID) + require.NotEmpty(t, results) + var sawTimeout bool + for _, r := range results { + if r.TicketId == "tkt-symbol" { + sawTimeout = true + require.Equal(t, audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE, r.ResultClass) + } + } + require.True(t, sawTimeout, "expected timeout-class result for deadline exceeded RPC") +} + +// TestDispatchEpoch_HappyPath_EmitsPassResult exercises the full PASS path: +// eligible ticket, valid cascade meta, GetCompoundProof returns 4 ranges of +// 256B each whose BLAKE3 hash matches resp.ProofHashHex. Dispatcher must +// emit PASS-class result with non-empty transcript + signature + derivation +// hash. +// +// Only RECENT is exercised here; OLD bucket has no eligible ticket and emits +// NO_ELIGIBLE, which is also asserted. +func TestDispatchEpoch_HappyPath_EmitsPassResult(t *testing.T) { + const epochID uint64 = 19 + anchor := makeAnchor(epochID, 200, "sn-target") + audit := &dispatchAuditModule{ + params: &audittypes.QueryParamsResponse{Params: defaultParams(audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL)}, + anchor: &audittypes.QueryEpochAnchorResponse{Anchor: anchor}, + assigned: &audittypes.QueryAssignedTargetsResponse{TargetSupernodeAccounts: []string{"sn-target"}}, + } + tickets := stubTicketProvider{tickets: map[string][]TicketDescriptor{ + "sn-target": {{TicketID: "tkt-happy", AnchorBlock: 100}}, + }} + meta := stubMetaProvider{ + meta: &actiontypes.CascadeMetadata{RqIdsIc: 0, RqIdsMax: 1, RqIdsIds: []string{"sym-0"}}, + size: 4 * 1024, + } + + // Construct a response with 4 ranges of 256 bytes each (deterministic + // content) and a matching BLAKE3 proof hash. + rangeBytes := make([][]byte, deterministic.LEP6CompoundRangesPerArtifact) + hasher := blake3.New(32, nil) + for i := range rangeBytes { + buf := make([]byte, deterministic.LEP6CompoundRangeLenBytes) + // Fill with i-stamped bytes for determinism. + for j := range buf { + buf[j] = byte((i*7 + j) & 0xFF) + } + rangeBytes[i] = buf + _, _ = hasher.Write(buf) + } + proofHashHex := hex.EncodeToString(hasher.Sum(nil)) + resp := &supernodepb.GetCompoundProofResponse{ + Ok: true, + RangeBytes: rangeBytes, + ProofHashHex: proofHashHex, + } + factory := &stubFactory{client: &stubCompoundClient{resp: resp}} + d, buf := newDispatcher(t, audit, factory, tickets, meta) + + require.NoError(t, d.DispatchEpoch(context.Background(), epochID)) + results := buf.CollectResults(epochID) + require.NotEmpty(t, results) + + var sawPass bool + for _, r := range results { + if r.ResultClass == audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS { + sawPass = true + require.Equal(t, "tkt-happy", r.TicketId) + require.Equal(t, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, r.BucketType) + require.NotEmpty(t, r.TranscriptHash) + require.NotEmpty(t, r.DerivationInputHash) + require.NotEmpty(t, r.ChallengerSignature) + require.NotEmpty(t, r.ArtifactKey) + } + } + require.True(t, sawPass, "expected a PASS-class result on happy path") +} + +func TestRecheck_GetParamsNilResponseIsClearAndDoesNotHoldDispatcherLock(t *testing.T) { + var dispatcher *LEP6Dispatcher + var sawUnlocked bool + audit := &dispatchAuditModule{} + audit.getParamsHook = func() { + if dispatcher.mu.TryLock() { + sawUnlocked = true + dispatcher.mu.Unlock() + } + } + dispatcher, _ = newDispatcher(t, audit, &stubFactory{}, NoTicketProvider{}, stubMetaProvider{}) + + candidate := recheck.Candidate{ + EpochID: 7, + TargetAccount: "sn-target", + TicketID: "ticket-1", + ChallengedTranscriptHash: "original-transcript", + OriginalReporter: "sn-reporter", + OriginalResultClass: audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, + } + + _, err := dispatcher.Recheck(context.Background(), candidate) + require.Error(t, err) + require.Contains(t, err.Error(), "lep6 recheck: get params returned nil response") + require.NotContains(t, err.Error(), "") + require.True(t, sawUnlocked, "Recheck must not hold dispatcher mutex while querying params") +} diff --git a/supernode/storage_challenge/lep6_recheck.go b/supernode/storage_challenge/lep6_recheck.go new file mode 100644 index 00000000..3b575fef --- /dev/null +++ b/supernode/storage_challenge/lep6_recheck.go @@ -0,0 +1,67 @@ +package storage_challenge + +import ( + "context" + "fmt" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/supernode/recheck" +) + +// Recheck executes a LEP-6 RECHECK-bucket proof for the candidate and returns +// the result shape expected by MsgSubmitStorageRecheckEvidence. +// +// LEP-6 review (Matee, 2026-05-06) — M11: previously this routine took +// d.mu.Lock(), shadow-swapped d.buffer to a temporary buffer, ran the +// RPC-bound dispatch, and swapped back — all under the dispatcher's main +// lock. That meant a slow recheck RPC blocked the per-epoch dispatcher loop +// (head-of-line blocking on d.mu) and risked losing dispatcher writes if +// `Append` was called against a swapped buffer concurrently. +// +// Fix: thread the temporary buffer as a parameter to dispatchTicket so the +// dispatcher's shared d.buffer is never mutated. No lock around the RPC. +func (d *LEP6Dispatcher) Recheck(ctx context.Context, c recheck.Candidate) (recheck.RecheckResult, error) { + if !c.Valid() { + return recheck.RecheckResult{}, fmt.Errorf("invalid recheck candidate") + } + paramsResp, err := d.client.Audit().GetParams(ctx) + if err != nil { + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: get params: %w", err) + } + if paramsResp == nil { + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: get params returned nil response") + } + params := paramsResp.Params + if params.StorageTruthEnforcementMode == audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED { + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: enforcement mode unspecified") + } + anchorResp, err := d.client.Audit().GetEpochAnchor(ctx, c.EpochID) + if err != nil { + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: get epoch anchor %d: %w", c.EpochID, err) + } + if anchorResp == nil { + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: epoch anchor not yet available for epoch %d", c.EpochID) + } + if anchorResp.Anchor.EpochId != c.EpochID { + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: epoch anchor not yet available for epoch %d", 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 { + return recheck.RecheckResult{}, err + } + results := tmp.CollectResults(c.EpochID) + for _, r := range results { + if r == nil || r.TicketId != c.TicketID || r.TargetSupernodeAccount != c.TargetAccount { + continue + } + cls := r.ResultClass + if cls == audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH { + cls = audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL + } + return recheck.RecheckResult{TranscriptHash: r.TranscriptHash, ResultClass: cls, Details: r.Details}, nil + } + return recheck.RecheckResult{}, fmt.Errorf("lep6 recheck: no result emitted for epoch=%d ticket=%s target=%s", c.EpochID, c.TicketID, c.TargetAccount) +} diff --git a/supernode/storage_challenge/result_buffer.go b/supernode/storage_challenge/result_buffer.go new file mode 100644 index 00000000..c12fbf92 --- /dev/null +++ b/supernode/storage_challenge/result_buffer.go @@ -0,0 +1,279 @@ +package storage_challenge + +import ( + "context" + "sort" + "sync" + "sync/atomic" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" + "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge" +) + +// Buffer accumulates StorageProofResults emitted by the per-epoch challenger +// loop and surfaces them to the host reporter (which submits MsgSubmitEpochReport). +// +// Buffer satisfies host_reporter.ProofResultProvider: +// +// CollectResults(epochID uint64) []*audittypes.StorageProofResult +// +// The chain audit keeper rejects an entire epoch report if its +// storage_proof_results slice exceeds MaxStorageProofResultsPerReport +// (lumera/x/audit/v1/types/keys.go:11-13, enforced in +// x/audit/v1/keeper/msg_submit_epoch_report.go:126-130). Because two +// independent challengers may produce overlapping result sets that combine +// past the cap, CollectResults applies a deterministic self-throttle. +// +// LEP-6 review (Matee, 2026-05-06) — H5: +// - Throttle drops by ARRIVAL ORDER (oldest-first), not by ticket_id lex. +// Sorting by content-addressed ticket_id let an attacker who can shape +// ticket IDs decide which rows reach the chain. Arrival order is +// attacker-uninfluenceable per challenger. +// - Fairness across (target, bucket) groups: drop from the LARGEST group +// first so no single (target, bucket) starves another. A target with +// many tickets cannot crowd out other targets. +// - Cross-supernode determinism: arrival order is per-process, but the +// final delivered slice is sorted by (BucketType, TicketId) so two +// independently-generated reports that started from the same input set +// deliver byte-identical messages to the chain. +// - Tiebreaker for equal-arrival rows: ticket_id lex ASC, then full proto +// bytes — ensures deterministic deletion when wall-clock collides. +// +// Buffer is safe for concurrent use. +type Buffer struct { + mu sync.Mutex + byEpoch map[uint64][]*bufferedResult + seq atomic.Uint64 +} + +// bufferedResult wraps a StorageProofResult with arrival metadata so the +// throttle algorithm can drop oldest rows first without leaking ordering +// constants into the public StorageProofResult shape. +type bufferedResult struct { + result *audittypes.StorageProofResult + arrivedAt time.Time + seq uint64 // monotonic per-process tiebreaker +} + +// NewBuffer returns an empty Buffer. +func NewBuffer() *Buffer { + return &Buffer{byEpoch: make(map[uint64][]*bufferedResult)} +} + +// Append stores result under epochID. Nil results are ignored. +func (b *Buffer) Append(epochID uint64, result *audittypes.StorageProofResult) { + if result == nil { + return + } + b.appendEntry(epochID, result, time.Now()) +} + +func (b *Buffer) appendEntry(epochID uint64, result *audittypes.StorageProofResult, arrivedAt time.Time) { + entry := &bufferedResult{ + result: result, + arrivedAt: arrivedAt, + seq: b.seq.Add(1), + } + b.mu.Lock() + defer b.mu.Unlock() + b.byEpoch[epochID] = append(b.byEpoch[epochID], entry) +} + +// RequeueResults puts previously drained results back under epochID. It is used +// by host_reporter when FULL-mode coverage is incomplete, so the next tick can +// retry instead of losing locally generated proofs. +func (b *Buffer) RequeueResults(epochID uint64, results []*audittypes.StorageProofResult) { + now := time.Now() + for _, result := range results { + if result != nil { + b.appendEntry(epochID, result, now) + } + } +} + +// HasEligibleResult reports whether the current in-memory buffer already has a +// non-NO_ELIGIBLE row for (epoch,target,bucket). It is intentionally scoped to +// this process/epoch; the current Lumera audit query interface does not expose +// the chain keeper's transcript-history index used by validateNoEligibleTicketConsistency. +func (b *Buffer) HasEligibleResult(epochID uint64, target string, bucket audittypes.StorageProofBucketType) bool { + b.mu.Lock() + defer b.mu.Unlock() + for _, entry := range b.byEpoch[epochID] { + if entry == nil || entry.result == nil { + continue + } + r := entry.result + if r.TargetSupernodeAccount == target && r.BucketType == bucket && + r.ResultClass != audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET { + return true + } + } + return false +} + +// CollectResults drains and returns the buffered results for epochID, applying +// the LEP-6 chain cap self-throttle. Results buffered for other epochs are left +// intact. The returned slice is sorted deterministically by +// (BucketType, EpochId, TicketId) so that downstream signing/serialisation is +// stable across challengers and re-runs. +func (b *Buffer) CollectResults(epochID uint64) []*audittypes.StorageProofResult { + b.mu.Lock() + matching := b.byEpoch[epochID] + delete(b.byEpoch, epochID) + b.mu.Unlock() + + if len(matching) == 0 { + return nil + } + + const maxKeep = storagechallenge.MaxStorageProofResultsPerReport + + if len(matching) > maxKeep { + matching = throttleResults(epochID, matching, maxKeep) + } + + out := make([]*audittypes.StorageProofResult, 0, len(matching)) + for _, e := range matching { + if e == nil || e.result == nil { + continue + } + out = append(out, e.result) + } + sortDeterministic(out) + return out +} + +// throttleResults enforces len(results) <= maxKeep using two passes: +// +// 1. Drop from the LARGEST (target, bucket) group first to maintain fairness; +// within each group drop the oldest arrival. Repeat until either +// (a) the slice fits, or (b) every group has shrunk to size 1. +// 2. If still over cap, drop the global oldest entry irrespective of group. +// +// Determinism: arrival timestamp is the primary key; (sequence, ticket_id) is +// the tiebreaker. Two challengers will produce the same delivered set if they +// observed the same arrival order — which is the case when both walked the +// dispatcher loop in the same order (the dispatcher is single-goroutine per +// epoch). For the cross-challenger case, the chain combines reports from +// multiple challengers anyway; a single challenger's deterministic local +// throttle is what matters here. +// +// A Warn log is emitted when throttling activates. +func throttleResults(epochID uint64, results []*bufferedResult, maxKeep int) []*bufferedResult { + if len(results) <= maxKeep { + return results + } + originalCount := len(results) + + // Group by (target, bucket). + type groupKey struct { + target string + bucket audittypes.StorageProofBucketType + } + groups := make(map[groupKey][]*bufferedResult) + for _, r := range results { + if r == nil || r.result == nil { + continue + } + k := groupKey{target: r.result.TargetSupernodeAccount, bucket: r.result.BucketType} + groups[k] = append(groups[k], r) + } + // Sort each group oldest-first. + for k := range groups { + sort.SliceStable(groups[k], func(i, j int) bool { + a, b := groups[k][i], groups[k][j] + if !a.arrivedAt.Equal(b.arrivedAt) { + return a.arrivedAt.Before(b.arrivedAt) + } + if a.seq != b.seq { + return a.seq < b.seq + } + return a.result.TicketId < b.result.TicketId + }) + } + + totalCount := func() int { + n := 0 + for _, g := range groups { + n += len(g) + } + return n + } + + // Phase 1: drop oldest from largest group while every group still has > 1 + // AND total > maxKeep. A "largest" tie is broken by the deterministic + // sort below — pick the group whose oldest entry's (target, bucket) sorts + // first lex so two challengers with the same input drop the same group. + for totalCount() > maxKeep { + var largestKey groupKey + largestSize := 0 + for k, g := range groups { + if len(g) > largestSize || + (len(g) == largestSize && (k.target < largestKey.target || + (k.target == largestKey.target && k.bucket < largestKey.bucket))) { + largestKey = k + largestSize = len(g) + } + } + if largestSize <= 1 { + break + } + groups[largestKey] = groups[largestKey][1:] + } + + // Phase 2: if still over cap, drop global oldest entry deterministically. + for totalCount() > maxKeep { + var oldestKey groupKey + var oldestEntry *bufferedResult + for k, g := range groups { + if len(g) == 0 { + continue + } + head := g[0] + if oldestEntry == nil || + head.arrivedAt.Before(oldestEntry.arrivedAt) || + (head.arrivedAt.Equal(oldestEntry.arrivedAt) && head.seq < oldestEntry.seq) { + oldestKey = k + oldestEntry = head + } + } + if oldestEntry == nil { + break + } + groups[oldestKey] = groups[oldestKey][1:] + } + + kept := make([]*bufferedResult, 0, maxKeep) + for _, g := range groups { + kept = append(kept, g...) + } + + dropped := originalCount - len(kept) + lep6metrics.IncDispatchThrottled("oldest-arrival-fair-by-target-bucket", dropped) + logtrace.Warn(context.Background(), "storage_challenge: result buffer throttled to chain cap", logtrace.Fields{ + "epoch_id": epochID, + "original": originalCount, + "kept": len(kept), + "dropped": dropped, + "cap": maxKeep, + "policy": "oldest-arrival-fair-by-target-bucket", + }) + + return kept +} + +// sortDeterministic orders results by (BucketType, TicketId). All results in +// a single CollectResults call share the same epoch, so EpochId would not +// further disambiguate. +func sortDeterministic(results []*audittypes.StorageProofResult) { + sort.SliceStable(results, func(i, j int) bool { + a, b := results[i], results[j] + if a.BucketType != b.BucketType { + return a.BucketType < b.BucketType + } + return a.TicketId < b.TicketId + }) +} diff --git a/supernode/storage_challenge/result_buffer_test.go b/supernode/storage_challenge/result_buffer_test.go new file mode 100644 index 00000000..252e1bea --- /dev/null +++ b/supernode/storage_challenge/result_buffer_test.go @@ -0,0 +1,348 @@ +package storage_challenge + +import ( + "fmt" + "reflect" + "sync" + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +const ( + bucketRecent = audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT + bucketOld = audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD +) + +func mkResult(bucket audittypes.StorageProofBucketType, ticket string) *audittypes.StorageProofResult { + return &audittypes.StorageProofResult{ + TicketId: ticket, + BucketType: bucket, + } +} + +func mkResultForTarget(bucket audittypes.StorageProofBucketType, ticket, target string) *audittypes.StorageProofResult { + return &audittypes.StorageProofResult{ + TicketId: ticket, + BucketType: bucket, + TargetSupernodeAccount: target, + } +} + +// ticketIDsOf extracts ticket IDs in slice order. +func ticketIDsOf(rs []*audittypes.StorageProofResult) []string { + out := make([]string, len(rs)) + for i, r := range rs { + out[i] = r.TicketId + } + return out +} + +func TestBuffer_BelowCap_ReturnsAllSortedDeterministically(t *testing.T) { + b := NewBuffer() + // Append in scrambled order; expect sort by (BucketType, TicketId). + inputs := []*audittypes.StorageProofResult{ + mkResult(bucketOld, "t-old-b"), + mkResult(bucketRecent, "t-recent-c"), + mkResult(bucketRecent, "t-recent-a"), + mkResult(bucketOld, "t-old-a"), + } + for _, r := range inputs { + b.Append(5, r) + } + got := b.CollectResults(5) + if len(got) != 4 { + t.Fatalf("want 4 results, got %d", len(got)) + } + // RECENT (=1) sorts before OLD (=2) because lower numeric enum. + want := []string{"t-recent-a", "t-recent-c", "t-old-a", "t-old-b"} + if !reflect.DeepEqual(ticketIDsOf(got), want) { + t.Fatalf("ordering mismatch:\n got: %v\n want: %v", ticketIDsOf(got), want) + } + // Buffer drained for epoch 5. + if got2 := b.CollectResults(5); len(got2) != 0 { + t.Fatalf("expected drained buffer, got %d results", len(got2)) + } +} + +func TestBuffer_AboveCap_DropsByArrivalAndFairness(t *testing.T) { + // LEP-6 review H5 (Matee): drop policy is now arrival-order with + // (target, bucket) fairness, not "non-RECENT first by ticket_id lex". + // 10 RECENT (target A) + 8 OLD (target B) = 18, cap 16 → 2 drops. + // Both groups have size > 1 so phase-1 fairness drops one from the + // LARGEST group first, then re-evaluates. Group A starts at 10, group B + // at 8 → first drop is A's oldest (the very first appended). Now A=9, + // B=8 → next drop is A's oldest again. Result: A=8, B=8. + b := NewBuffer() + for i := 0; i < 10; i++ { + b.Append(7, mkResultForTarget(bucketRecent, fmt.Sprintf("recent-%02d", i), "tA")) + } + for i := 0; i < 8; i++ { + b.Append(7, mkResultForTarget(bucketOld, fmt.Sprintf("old-%02d", i), "tB")) + } + got := b.CollectResults(7) + if len(got) != 16 { + t.Fatalf("want 16 results, got %d", len(got)) + } + var nRecent, nOld int + for _, r := range got { + switch r.BucketType { + case bucketRecent: + nRecent++ + case bucketOld: + nOld++ + } + } + // Fairness: largest group (RECENT/tA, 10) drops 2 oldest; OLD/tB (8) untouched. + if nRecent != 8 || nOld != 8 { + t.Fatalf("want 8 RECENT + 8 OLD (fairness), got %d RECENT + %d OLD", nRecent, nOld) + } + // The two oldest in the dropped group are recent-00 and recent-01. + for _, r := range got { + if r.TicketId == "recent-00" || r.TicketId == "recent-01" { + t.Fatalf("expected oldest entries from largest group dropped; %q present", r.TicketId) + } + } +} + +func TestBuffer_AboveCap_OnlyRecent_DropsOldestArrival(t *testing.T) { + // LEP-6 review H5: 20 RECENT all same (target,bucket) group → fairness + // phase drops oldest 4 by ARRIVAL ORDER. (Within a single group, arrival + // order is the only key — deterministic per challenger.) + b := NewBuffer() + for i := 0; i < 20; i++ { + b.Append(9, mkResultForTarget(bucketRecent, fmt.Sprintf("r-%02d", i), "t1")) + } + got := b.CollectResults(9) + if len(got) != 16 { + t.Fatalf("want 16 results, got %d", len(got)) + } + // r-00..r-03 are oldest arrivals → dropped. r-04..r-19 survive. + // Final delivered slice is sorted by (Bucket, TicketId) → ASC ticket id. + want := []string{ + "r-04", "r-05", "r-06", "r-07", "r-08", "r-09", + "r-10", "r-11", "r-12", "r-13", "r-14", "r-15", + "r-16", "r-17", "r-18", "r-19", + } + if !reflect.DeepEqual(ticketIDsOf(got), want) { + t.Fatalf("ordering mismatch:\n got: %v\n want: %v", ticketIDsOf(got), want) + } +} + +func TestBuffer_DeterministicSorting(t *testing.T) { + build := func() []*audittypes.StorageProofResult { + b := NewBuffer() + // Mix and match in a deliberately scrambled order. + seqs := []*audittypes.StorageProofResult{ + mkResult(bucketOld, "ticket-z"), + mkResult(bucketRecent, "ticket-m"), + mkResult(bucketOld, "ticket-a"), + mkResult(bucketRecent, "ticket-b"), + mkResult(bucketRecent, "ticket-aa"), + mkResult(bucketOld, "ticket-c"), + } + for _, r := range seqs { + b.Append(11, r) + } + return b.CollectResults(11) + } + a := ticketIDsOf(build()) + c := ticketIDsOf(build()) + if !reflect.DeepEqual(a, c) { + t.Fatalf("non-deterministic output:\n run1: %v\n run2: %v", a, c) + } +} + +func TestBuffer_ConcurrentAppendDrain(t *testing.T) { + b := NewBuffer() + const writers = 8 + const perWriter = 50 + + var wg sync.WaitGroup + wg.Add(writers) + for w := 0; w < writers; w++ { + go func(w int) { + defer wg.Done() + for i := 0; i < perWriter; i++ { + bucket := bucketRecent + if i%3 == 0 { + bucket = bucketOld + } + b.Append(13, mkResult(bucket, fmt.Sprintf("w%d-i%03d", w, i))) + } + }(w) + } + + // Concurrent drainer racing with writers — also exercises the lock under -race. + done := make(chan struct{}) + go func() { + for { + select { + case <-done: + return + default: + _ = b.CollectResults(13) + } + } + }() + + wg.Wait() + close(done) + // Drain leftover (whatever the racing collector didn't drain). + _ = b.CollectResults(13) + + // Buffer must be empty for the epoch. + if got := b.CollectResults(13); len(got) != 0 { + t.Fatalf("expected empty buffer after final drain, got %d", len(got)) + } +} + +// TestBuffer_FullModeAssignedTargetCoverageBelowCap is the LEP-6 v3-plan PR3 +// item-5 invariant guard: when the dispatcher emits the realistic chain-bound +// workload (one assigned target → one RECENT + one OLD per epoch, far under +// the 16-result cap), the buffer MUST surface both bucket entries for that +// target untouched. This is the only path that runs in production today +// because chain-side AssignTargets returns at most one target per epoch. +// +// Note: the throttle policy ("drop non-RECENT first") does NOT preserve +// per-target RECENT+OLD coverage if the buffer ever exceeds cap. That is +// intentional and acceptable here because the dispatcher is structurally +// bounded to ≤2 emissions per assigned target. If a future change widens +// emissions (e.g. multiple assigned targets per epoch), the throttle policy +// must be revisited — see TestBuffer_OverCap_DropPolicyIsNotTargetAware +// below for the explicit pin of current behavior. +func TestBuffer_FullModeAssignedTargetCoverageBelowCap(t *testing.T) { + const target = "lumera1assignedtarget000000000000000000target" + b := NewBuffer() + + // Realistic FULL-mode emission: one RECENT + one OLD for the assigned + // target, plus a small amount of unrelated-target carryover (e.g. from + // a parallel challenger run for a different epoch slice). + b.Append(42, mkResultForTarget(bucketRecent, "ticket-recent-A", target)) + b.Append(42, mkResultForTarget(bucketOld, "ticket-old-A", target)) + b.Append(42, mkResultForTarget(bucketRecent, "ticket-recent-other", "lumera1other00000000000000000000000000other")) + b.Append(42, mkResultForTarget(bucketOld, "ticket-old-other", "lumera1other00000000000000000000000000other")) + + got := b.CollectResults(42) + if len(got) != 4 { + t.Fatalf("want 4 results below cap, got %d", len(got)) + } + + var sawTargetRecent, sawTargetOld bool + for _, r := range got { + if r.TargetSupernodeAccount != target { + continue + } + switch r.BucketType { + case bucketRecent: + if sawTargetRecent { + t.Fatalf("duplicate RECENT for assigned target") + } + sawTargetRecent = true + case bucketOld: + if sawTargetOld { + t.Fatalf("duplicate OLD for assigned target") + } + sawTargetOld = true + } + } + if !sawTargetRecent { + t.Fatalf("FULL coverage invariant violated: assigned target RECENT entry missing from CollectResults output") + } + if !sawTargetOld { + t.Fatalf("FULL coverage invariant violated: assigned target OLD entry missing from CollectResults output") + } +} + +// TestBuffer_OverCap_FairnessByTargetBucket pins the new H5 throttle policy: +// when over cap, drop oldest from the LARGEST (target, bucket) group first +// so a single noisy target cannot starve other targets. +// +// Setup: 14 RECENT for noisyTarget + 1 RECENT + 2 OLD for assignedTarget + +// 1 OLD for fillerTarget = 18 total. Cap 16, must drop 2. +// - Largest group is (noisyTarget, RECENT)=14. Phase-1 drops oldest 2 +// entries from this group. All other groups untouched. +// - assignedTarget's coverage (1 RECENT + 1 OLD) survives. +func TestBuffer_OverCap_FairnessByTargetBucket(t *testing.T) { + const noisy = "lumera1noisy0000000000000000000000000000noisy" + const assigned = "lumera1assignedtarget000000000000000000target" + const filler = "lumera1other00000000000000000000000000other" + + b := NewBuffer() + for i := 0; i < 14; i++ { + b.Append(99, mkResultForTarget(bucketRecent, fmt.Sprintf("noisy-recent-%02d", i), noisy)) + } + b.Append(99, mkResultForTarget(bucketRecent, "assigned-recent-A", assigned)) + b.Append(99, mkResultForTarget(bucketOld, "assigned-old-A", assigned)) + b.Append(99, mkResultForTarget(bucketOld, "assigned-old-B", assigned)) + b.Append(99, mkResultForTarget(bucketOld, "filler-old-zzz", filler)) + + got := b.CollectResults(99) + if len(got) != 16 { + t.Fatalf("want 16 (cap), got %d", len(got)) + } + + // Both assignedTarget rows (1 RECENT + 2 OLD = 3 entries) must survive. + var assignedKept int + for _, r := range got { + if r.TargetSupernodeAccount == assigned { + assignedKept++ + } + } + if assignedKept != 3 { + t.Fatalf("fairness violated: assigned target should retain all 3 entries, got %d", assignedKept) + } + // Filler (single-entry group) must survive. + var fillerKept bool + for _, r := range got { + if r.TicketId == "filler-old-zzz" { + fillerKept = true + } + } + if !fillerKept { + t.Fatalf("fairness violated: single-entry filler group should survive") + } + // Noisy target should have lost exactly 2 entries (its two oldest: + // noisy-recent-00 and noisy-recent-01). + var noisyKept int + for _, r := range got { + if r.TargetSupernodeAccount == noisy { + noisyKept++ + if r.TicketId == "noisy-recent-00" || r.TicketId == "noisy-recent-01" { + t.Fatalf("expected oldest noisy entries dropped; %q present", r.TicketId) + } + } + } + if noisyKept != 12 { + t.Fatalf("noisy target should keep 12 (14-2), got %d", noisyKept) + } +} + +func TestBuffer_PerEpochIsolation(t *testing.T) { + b := NewBuffer() + b.Append(5, mkResult(bucketRecent, "e5-a")) + b.Append(5, mkResult(bucketOld, "e5-b")) + b.Append(6, mkResult(bucketRecent, "e6-a")) + b.Append(6, mkResult(bucketOld, "e6-b")) + + got5 := b.CollectResults(5) + if len(got5) != 2 { + t.Fatalf("epoch 5: want 2, got %d", len(got5)) + } + for _, r := range got5 { + if r.TicketId != "e5-a" && r.TicketId != "e5-b" { + t.Fatalf("epoch 5 leaked foreign ticket %q", r.TicketId) + } + } + + // Epoch 6 must remain intact. + got6 := b.CollectResults(6) + if len(got6) != 2 { + t.Fatalf("epoch 6 lost data: want 2, got %d", len(got6)) + } + for _, r := range got6 { + if r.TicketId != "e6-a" && r.TicketId != "e6-b" { + t.Fatalf("epoch 6 leaked foreign ticket %q", r.TicketId) + } + } +} diff --git a/supernode/storage_challenge/service.go b/supernode/storage_challenge/service.go index b2227de0..de449a23 100644 --- a/supernode/storage_challenge/service.go +++ b/supernode/storage_challenge/service.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "net" - "net/url" "sort" "strconv" "strings" @@ -20,6 +19,7 @@ import ( "github.com/LumeraProtocol/supernode/v2/pkg/lumera" "github.com/LumeraProtocol/supernode/v2/pkg/net/credentials" grpcclient "github.com/LumeraProtocol/supernode/v2/pkg/net/grpc/client" + "github.com/LumeraProtocol/supernode/v2/pkg/netutil" "github.com/LumeraProtocol/supernode/v2/pkg/storage/queries" "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge/deterministic" "github.com/cosmos/cosmos-sdk/crypto/keyring" @@ -69,6 +69,20 @@ type Service struct { grpcClient *grpcclient.Client grpcOpts *grpcclient.ClientOptions + + // lep6 is the LEP-6 compound storage challenge dispatcher. Optional: + // if nil the legacy fixed-range path is the only active flow. When + // non-nil, the dispatcher runs once per new epoch in addition to the + // legacy loop. Mode gating (UNSPECIFIED skips) lives inside + // LEP6Dispatcher.DispatchEpoch. + lep6 *LEP6Dispatcher +} + +// SetLEP6Dispatcher attaches the LEP-6 compound-challenge dispatcher. +// May be called once before Run; nil-safe at the call site (Run skips +// LEP-6 work when the field is nil). +func (s *Service) SetLEP6Dispatcher(d *LEP6Dispatcher) { + s.lep6 = d } type Config struct { @@ -153,8 +167,26 @@ func (s *Service) Run(ctx context.Context) error { ticker := time.NewTicker(s.cfg.PollInterval) defer ticker.Stop() + // LEP-6 review M9 (Matee, 2026-05-06): seed lastRunEpoch from persisted + // state so a restart does not re-dispatch / re-submit the most-recent + // epoch. A read failure is logged and treated as a fresh start (the + // dispatcher loop will re-derive epoch eligibility from the current + // chain height before submitting). var lastRunEpoch uint64 var lastRunOK bool + if s.store != nil { + if persisted, ok, err := s.store.GetStorageChallengeState(ctx, queries.LEP6LastSubmittedEpochKey); err != nil { + logtrace.Warn(ctx, "storage challenge: failed to read persisted last-submitted-epoch; starting fresh", logtrace.Fields{ + logtrace.FieldError: err.Error(), + }) + } else if ok { + lastRunEpoch = persisted + lastRunOK = true + logtrace.Info(ctx, "storage challenge: resumed from persisted last-submitted-epoch", logtrace.Fields{ + "epoch_id": persisted, + }) + } + } var loggedAlreadyRanEpoch uint64 var loggedNotSelectedEpoch uint64 var loggedDisabledEpoch uint64 @@ -253,12 +285,43 @@ func (s *Service) Run(ctx context.Context) error { continue } + // LEP-6 compound dispatch runs alongside the legacy single-range + // challenge for forward compatibility. The dispatcher gates + // internally on StorageTruthEnforcementMode (UNSPECIFIED skips), + // so it is dormant under chains that have not enabled storage + // truth enforcement and a no-op cost otherwise. + if s.lep6 != nil { + if err := s.lep6.DispatchEpoch(ctx, epochID); err != nil { + logtrace.Warn(ctx, "lep6 dispatch error", logtrace.Fields{ + "epoch_id": epochID, + "error": err.Error(), + }) + } + } + lastRunEpoch = epochID lastRunOK = true + s.persistLastRunEpoch(ctx, epochID) } } } +// persistLastRunEpoch writes the dispatcher's last-completed epoch to the +// supernode SQLite store so that a restart does not re-dispatch the same +// epoch (LEP-6 review M9). Best-effort: a write failure is logged but does +// not fail the tick — worst case we re-dispatch one epoch on the next start. +func (s *Service) persistLastRunEpoch(ctx context.Context, epochID uint64) { + if s.store == nil { + return + } + if err := s.store.SetStorageChallengeState(ctx, queries.LEP6LastSubmittedEpochKey, epochID); err != nil { + logtrace.Warn(ctx, "storage challenge: failed to persist last-submitted-epoch", logtrace.Fields{ + "epoch_id": epochID, + logtrace.FieldError: err.Error(), + }) + } +} + func (s *Service) initClients() error { validator := lumera.NewSecureKeyExchangeValidator(s.lumera) @@ -486,71 +549,13 @@ func (s *Service) supernodeGRPCAddr(ctx context.Context, supernodeAccount string // both forms: // - "host" -> use our configured default gRPC port // - "host:port" -> use the stored port as the dial target - host, port, ok := parseHostAndPort(raw, int(s.grpcPort)) + host, port, ok := netutil.ParseHostAndPort(raw, int(s.grpcPort)) if !ok || strings.TrimSpace(host) == "" { return "", fmt.Errorf("invalid supernode address for %s: %q", supernodeAccount, raw) } return net.JoinHostPort(strings.TrimSpace(host), strconv.Itoa(port)), nil } -// parseHostAndPort parses a "host" or "host:port" string and returns a host and port. -// If a port is not present, defaultPort is returned. If a port is present but invalid, -func parseHostAndPort(address string, defaultPort int) (host string, port int, ok bool) { - address = strings.TrimSpace(address) - if address == "" { - return "", 0, false - } - - // If it looks like a URL, parse and use the host[:port] portion. - if u, err := url.Parse(address); err == nil && u.Host != "" { - address = u.Host - } - - if h, p, err := net.SplitHostPort(address); err == nil { - h = strings.TrimSpace(h) - if h == "" { - return "", 0, false - } - if n, err := strconv.Atoi(p); err == nil && n > 0 && n <= 65535 { - return h, n, true - } - return h, defaultPort, true - } - - // No port present. Treat it as a raw host if it is plausibly valid; otherwise fail. - host = strings.TrimSpace(address) - if host == "" { - return "", 0, false - } - - // Accept bracketed IPv6 literal without a port (e.g. "[2001:db8::1]") by stripping brackets. - if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") && strings.Count(host, "]") == 1 { - host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") - host = strings.TrimSpace(host) - if host == "" { - return "", 0, false - } - } - - // Reject obviously malformed inputs (paths, fragments, userinfo, whitespace, or stray brackets). - if strings.ContainsAny(host, " \t\r\n/\\?#@[]") { - return "", 0, false - } - - // If it contains ':' it must be a valid IPv6 literal (optionally with a zone, e.g. "fe80::1%eth0"). - if strings.Contains(host, ":") { - ipPart := host - if i := strings.IndexByte(ipPart, '%'); i >= 0 { - ipPart = ipPart[:i] - } - if net.ParseIP(ipPart) == nil { - return "", 0, false - } - } - - return host, defaultPort, true -} - func (s *Service) callGetSliceProof(ctx context.Context, remoteIdentity string, address string, req *supernode.GetSliceProofRequest, timeout time.Duration) (*supernode.GetSliceProofResponse, error) { cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() diff --git a/supernode/storage_challenge/ticket_provider.go b/supernode/storage_challenge/ticket_provider.go new file mode 100644 index 00000000..1ad65002 --- /dev/null +++ b/supernode/storage_challenge/ticket_provider.go @@ -0,0 +1,118 @@ +package storage_challenge + +import ( + "context" + "sort" + "strings" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + lep6metrics "github.com/LumeraProtocol/supernode/v2/pkg/metrics/lep6" + "github.com/cosmos/gogoproto/proto" +) + +// ChainTicketProvider discovers finalized cascade actions assigned to a target +// supernode via the final Lumera action query API. It is intentionally small: +// the dispatcher only needs ticket/action IDs and their register-time block +// heights for LEP-6 bucket classification. +type ChainTicketProvider struct { + client lumera.Client +} + +// NewChainTicketProvider constructs a production TicketProvider backed by +// x/action ListActionsBySuperNode. +func NewChainTicketProvider(client lumera.Client) *ChainTicketProvider { + return &ChainTicketProvider{client: client} +} + +// TicketsForTarget returns finalized cascade actions that include the target +// supernode in their action.SuperNodes assignment list. +func (p *ChainTicketProvider) TicketsForTarget(ctx context.Context, targetSupernodeAccount string) ([]TicketDescriptor, error) { + if p == nil || p.client == nil || p.client.Action() == nil { + return nil, nil + } + target := strings.TrimSpace(targetSupernodeAccount) + if target == "" { + return nil, nil + } + + resp, err := p.client.Action().ListActionsBySuperNode(ctx, target) + if err != nil || resp == nil { + return nil, err + } + + out := make([]TicketDescriptor, 0, len(resp.Actions)) + seen := make(map[string]struct{}, len(resp.Actions)) + for _, act := range resp.Actions { + if !isEligibleCascadeAction(act, target) { + lep6metrics.IncTicketDiscovery("ineligible") + continue + } + id := strings.TrimSpace(act.ActionID) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + lep6metrics.IncTicketDiscovery("eligible") + out = append(out, TicketDescriptor{TicketID: id, AnchorBlock: act.BlockHeight}) + } + + sort.Slice(out, func(i, j int) bool { return out[i].TicketID < out[j].TicketID }) + return out, nil +} + +func isEligibleCascadeAction(act *actiontypes.Action, target string) bool { + if act == nil { + return false + } + if act.ActionType != actiontypes.ActionTypeCascade { + return false + } + // LEP-6 challenges storage only after cascade finalization. Lumera marks + // finalized/approved actions as DONE/APPROVED depending on the workflow + // phase; reject pending/processing/rejected/failed/expired actions. + if act.State != actiontypes.ActionStateDone && act.State != actiontypes.ActionStateApproved { + return false + } + if act.BlockHeight <= 0 { + return false + } + if !hasValidCascadeMetadata(act.Metadata) { + return false + } + for _, sn := range act.SuperNodes { + if strings.TrimSpace(sn) == target { + return true + } + } + return false +} + +func hasValidCascadeMetadata(raw []byte) bool { + if len(raw) == 0 { + return false + } + var meta actiontypes.CascadeMetadata + if err := proto.Unmarshal(raw, &meta); err != nil { + return false + } + if strings.TrimSpace(meta.DataHash) == "" { + return false + } + if meta.RqIdsMax == 0 || len(meta.RqIdsIds) == 0 { + return false + } + // LEP-6 review M10 (Matee, 2026-05-06): a ticket is eligible if AT LEAST + // ONE artifact class has a non-zero count. Previously we required BOTH + // counts to be > 0, which silently hid INDEX-only or SYMBOL-only tickets + // from the dispatcher. The class roll handles per-class emptiness via + // SelectArtifactClass returning UNSPECIFIED → caller emits NO_ELIGIBLE + // (post-H6 fix). Both zero remains invisible (legacy preserve). + if meta.IndexArtifactCount == 0 && meta.SymbolArtifactCount == 0 { + return false + } + return true +} diff --git a/supernode/storage_challenge/ticket_provider_test.go b/supernode/storage_challenge/ticket_provider_test.go new file mode 100644 index 00000000..95981517 --- /dev/null +++ b/supernode/storage_challenge/ticket_provider_test.go @@ -0,0 +1,60 @@ +package storage_challenge + +import ( + "context" + "testing" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + lumeraMock "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + actionmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/action" + "github.com/cosmos/gogoproto/proto" + "go.uber.org/mock/gomock" +) + +func TestChainTicketProviderFiltersFinalizedCascadeActions(t *testing.T) { + ctrl := gomock.NewController(t) + client := lumeraMock.NewMockClient(ctrl) + actions := actionmod.NewMockModule(ctrl) + + metadata := validCascadeMetadata(t) + client.EXPECT().Action().Return(actions).Times(2) + actions.EXPECT().ListActionsBySuperNode(gomock.Any(), "sn-target").Return(&actiontypes.QueryListActionsBySuperNodeResponse{Actions: []*actiontypes.Action{ + {ActionID: "sym-old", ActionType: actiontypes.ActionTypeCascade, State: actiontypes.ActionStateDone, BlockHeight: 99, SuperNodes: []string{"sn-target"}, Metadata: metadata}, + {ActionID: "sym-approved", ActionType: actiontypes.ActionTypeCascade, State: actiontypes.ActionStateApproved, BlockHeight: 100, SuperNodes: []string{"sn-target"}, Metadata: metadata}, + {ActionID: "sym-old", ActionType: actiontypes.ActionTypeCascade, State: actiontypes.ActionStateDone, BlockHeight: 99, SuperNodes: []string{"sn-target"}, Metadata: metadata}, // duplicate + {ActionID: "pending", ActionType: actiontypes.ActionTypeCascade, State: actiontypes.ActionStatePending, BlockHeight: 101, SuperNodes: []string{"sn-target"}, Metadata: metadata}, + {ActionID: "wrong-type", ActionType: actiontypes.ActionTypeSense, State: actiontypes.ActionStateDone, BlockHeight: 102, SuperNodes: []string{"sn-target"}, Metadata: metadata}, + {ActionID: "wrong-target", ActionType: actiontypes.ActionTypeCascade, State: actiontypes.ActionStateDone, BlockHeight: 103, SuperNodes: []string{"other"}, Metadata: metadata}, + {ActionID: "zero-height", ActionType: actiontypes.ActionTypeCascade, State: actiontypes.ActionStateDone, BlockHeight: 0, SuperNodes: []string{"sn-target"}, Metadata: metadata}, + {ActionID: "bad-metadata", ActionType: actiontypes.ActionTypeCascade, State: actiontypes.ActionStateDone, BlockHeight: 104, SuperNodes: []string{"sn-target"}, Metadata: []byte("not-proto")}, + }}, nil) + + got, err := NewChainTicketProvider(client).TicketsForTarget(context.Background(), "sn-target") + if err != nil { + t.Fatalf("TicketsForTarget returned error: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 eligible tickets, got %d: %#v", len(got), got) + } + if got[0].TicketID != "sym-approved" || got[0].AnchorBlock != 100 { + t.Fatalf("first sorted ticket mismatch: %#v", got[0]) + } + if got[1].TicketID != "sym-old" || got[1].AnchorBlock != 99 { + t.Fatalf("second sorted ticket mismatch: %#v", got[1]) + } +} + +func validCascadeMetadata(t *testing.T) []byte { + t.Helper() + bz, err := proto.Marshal(&actiontypes.CascadeMetadata{ + DataHash: "hash", + RqIdsMax: 3, + RqIdsIds: []string{"rq-1"}, + IndexArtifactCount: 1, + SymbolArtifactCount: 1, + }) + if err != nil { + t.Fatalf("marshal metadata: %v", err) + } + return bz +} diff --git a/supernode/storage_challenge/wave3_regression_test.go b/supernode/storage_challenge/wave3_regression_test.go new file mode 100644 index 00000000..e50a1ee9 --- /dev/null +++ b/supernode/storage_challenge/wave3_regression_test.go @@ -0,0 +1,148 @@ +package storage_challenge + +import ( + "context" + "fmt" + "testing" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/stretchr/testify/require" +) + +// Wave 3 — LEP-6 PR286 review fix regression tests. +// +// Coverage: +// - H6: SelectArtifactClass with empty rolled class emits NO_ELIGIBLE_TICKET +// (no class swap). Verified end-to-end through DispatchEpoch: +// * INDEX-only ticket where the class roll lands on SYMBOL → NO_ELIGIBLE. +// * SYMBOL-only ticket where the class roll lands on INDEX → NO_ELIGIBLE. +// * NO_ELIGIBLE row keeps ticket_id="" (chain validator requirement). +// - L5: when NO_ELIGIBLE is emitted post-class-roll, the selected ticket id +// must NOT leak into the chain row's TicketId field (chain rejects). +// +// H4/H5 invariants are covered by lep6_dispatch_test.go + +// result_buffer_test.go after the wave-3 rewrites; this file targets the +// behavioural regressions specific to H6/L5 that did not have a direct test +// before this wave. + +// TestDispatchEpoch_H6_NoSwapEmitsNoEligible_TicketIdEmpty exercises the +// post-Wave-3 SelectArtifactClass behavior: with `tkt-T0` (rolls SYMBOL when +// both classes are present) and indexCount=0, the function must return +// UNSPECIFIED — wait, that's only the indexCount=0 + INDEX-roll case. For +// SYMBOL-roll + indexCount=0 we still return SYMBOL and the dispatcher hits +// the meta validation. So instead we use a ticket id that rolls INDEX with +// indexCount=0 → UNSPECIFIED → NO_ELIGIBLE. +func TestDispatchEpoch_H6_RollEmptyEmitsNoEligibleNotSwap(t *testing.T) { + const epochID uint64 = 4242 + anchor := makeAnchor(epochID, 200, "sn-target") + 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"}}, + } + // `tkt-timeout` rolls INDEX under makeAnchor's seed (verified empirically; + // see find_symbol_roll.go probe). With indexCount=0 the post-Wave-3 + // behaviour MUST be UNSPECIFIED → NO_ELIGIBLE_TICKET. Pre-Wave-3 code + // would have swapped to SYMBOL and tried to dispatch. + tickets := stubTicketProvider{tickets: map[string][]TicketDescriptor{ + "sn-target": {{TicketID: "tkt-timeout", AnchorBlock: 100}}, + }} + // IndexArtifactCount derives from RqIdsIc, SymbolArtifactCount from len(RqIdsIds). + // RqIdsIc=0 → INDEX class empty. + meta := stubMetaProvider{ + meta: &actiontypes.CascadeMetadata{ + RqIdsIc: 0, + RqIdsMax: 1, + RqIdsIds: []string{"sym-0"}, // SYMBOL count = 1 + }, + size: 4 * 1024, + } + d, buf := newDispatcher(t, audit, &stubFactory{}, tickets, meta) + + require.NoError(t, d.DispatchEpoch(context.Background(), epochID)) + results := buf.CollectResults(epochID) + require.NotEmpty(t, results) + + var sawNoEligible bool + for _, r := range results { + if r.TargetSupernodeAccount == "sn-target" && + r.BucketType == audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT && + r.ResultClass == audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET { + sawNoEligible = true + // L5: chain validator (msg_submit_epoch_report_storage_proofs.go:92-94) + // rejects NO_ELIGIBLE rows with non-empty ticket_id. Ensure we did + // NOT leak the selected ticket id into the row. + require.Equal(t, "", r.TicketId, + "H6/L5: NO_ELIGIBLE row must keep ticket_id=\"\" (got %q)", r.TicketId) + require.Equal(t, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, r.ArtifactClass, + "H6: NO_ELIGIBLE row must keep artifact_class=UNSPECIFIED") + require.NotEmpty(t, r.ChallengerSignature, + "H4: NO_ELIGIBLE row must carry a non-empty signature") + } + } + require.True(t, sawNoEligible, "expected NO_ELIGIBLE_TICKET row in RECENT bucket") +} + +// TestDispatchEpoch_H6_SymbolEmptyAlsoEmitsNoEligible covers the inverse case: +// SYMBOL-rolled ticket where SymbolArtifactCount=0 must emit NO_ELIGIBLE. +func TestDispatchEpoch_H6_SymbolEmptyEmitsNoEligible(t *testing.T) { + const epochID uint64 = 4243 + anchor := makeAnchor(epochID, 200, "sn-target") + 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"}}, + } + // `tkt-happy` rolls SYMBOL (verified). With SymbolArtifactCount=0 the + // dispatcher must emit NO_ELIGIBLE rather than swapping to INDEX. + tickets := stubTicketProvider{tickets: map[string][]TicketDescriptor{ + "sn-target": {{TicketID: "tkt-happy", AnchorBlock: 100}}, + }} + meta := stubMetaProvider{ + meta: &actiontypes.CascadeMetadata{ + RqIdsIc: 3, // INDEX class non-empty + RqIdsMax: 1, + RqIdsIds: []string{}, // SYMBOL count = 0 + }, + size: 4 * 1024, + } + d, buf := newDispatcher(t, audit, &stubFactory{}, tickets, meta) + + require.NoError(t, d.DispatchEpoch(context.Background(), epochID)) + results := buf.CollectResults(epochID) + require.NotEmpty(t, results) + + var sawNoEligibleRecent bool + for _, r := range results { + if r.BucketType == audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT && + r.ResultClass == audittypes.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_NO_ELIGIBLE_TICKET { + sawNoEligibleRecent = true + require.Equal(t, "", r.TicketId) + require.Equal(t, audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, r.ArtifactClass) + } + } + require.True(t, sawNoEligibleRecent, "SYMBOL-empty + SYMBOL-roll must emit NO_ELIGIBLE") +} + +// TestBuffer_H5_DeterministicCrossChallenger pins H5's deterministic-tiebreak +// invariant: two challengers that observe entries in the same arrival order +// must produce identical drop decisions. Sequence number provides +// monotonicity even when wall-clock timestamps coincide on fast paths. +func TestBuffer_H5_DeterministicCrossChallenger(t *testing.T) { + build := func() []string { + b := NewBuffer() + // 18 entries across 2 (target, bucket) groups in a fixed arrival order. + for i := 0; i < 10; i++ { + b.Append(123, mkResultForTarget(bucketRecent, fmt.Sprintf("rA-%02d", i), "tA")) + } + for i := 0; i < 8; i++ { + b.Append(123, mkResultForTarget(bucketOld, fmt.Sprintf("oB-%02d", i), "tB")) + } + return ticketIDsOf(b.CollectResults(123)) + } + a := build() + b := build() + require.Equal(t, a, b, "two runs with identical arrival order must produce identical kept set") + require.Len(t, a, 16) +} diff --git a/supernode/storage_challenge/wave3_ticket_provider_test.go b/supernode/storage_challenge/wave3_ticket_provider_test.go new file mode 100644 index 00000000..6110ca67 --- /dev/null +++ b/supernode/storage_challenge/wave3_ticket_provider_test.go @@ -0,0 +1,74 @@ +package storage_challenge + +import ( + "context" + "testing" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + lumeraMock "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + actionmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/action" + "github.com/cosmos/gogoproto/proto" + "go.uber.org/mock/gomock" +) + +// Wave 3 — M10 regression. Pre-Wave-3 the eligibility filter required BOTH +// IndexArtifactCount AND SymbolArtifactCount > 0, silently hiding INDEX-only +// or SYMBOL-only tickets from the dispatcher. Post-Wave-3 a ticket is +// eligible if AT LEAST ONE class is non-zero. Both-zero remains invisible. +func TestChainTicketProvider_M10_AcceptsAtLeastOneClass(t *testing.T) { + cases := []struct { + name string + indexCount uint32 + symbolCount uint32 + eligible bool + }{ + {"index_only", 1, 0, true}, + {"symbol_only", 0, 1, true}, + {"both", 1, 1, true}, + {"both_zero_legacy_invisible", 0, 0, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + client := lumeraMock.NewMockClient(ctrl) + actions := actionmod.NewMockModule(ctrl) + + meta := &actiontypes.CascadeMetadata{ + DataHash: "h", + RqIdsMax: 3, + RqIdsIds: []string{"rq-1"}, + IndexArtifactCount: tc.indexCount, + SymbolArtifactCount: tc.symbolCount, + } + metaBytes, err := proto.Marshal(meta) + if err != nil { + t.Fatalf("marshal meta: %v", err) + } + + client.EXPECT().Action().Return(actions).Times(2) + actions.EXPECT().ListActionsBySuperNode(gomock.Any(), "sn-target").Return( + &actiontypes.QueryListActionsBySuperNodeResponse{ + Actions: []*actiontypes.Action{{ + ActionID: "sym-1", + ActionType: actiontypes.ActionTypeCascade, + State: actiontypes.ActionStateDone, + BlockHeight: 100, + SuperNodes: []string{"sn-target"}, + Metadata: metaBytes, + }}, + }, nil) + + got, err := NewChainTicketProvider(client).TicketsForTarget(context.Background(), "sn-target") + if err != nil { + t.Fatalf("TicketsForTarget: %v", err) + } + gotEligible := len(got) == 1 + if gotEligible != tc.eligible { + t.Fatalf("M10 regression: index=%d symbol=%d → eligible=%v want=%v", + tc.indexCount, tc.symbolCount, gotEligible, tc.eligible) + } + }) + } +} diff --git a/supernode/transport/grpc/self_healing/handler.go b/supernode/transport/grpc/self_healing/handler.go new file mode 100644 index 00000000..bb03888c --- /dev/null +++ b/supernode/transport/grpc/self_healing/handler.go @@ -0,0 +1,231 @@ +// Package self_healing implements the §19 healer-served-path transport. +// +// LEP-6 §19 requires verifiers to fetch reconstructed bytes directly from +// the assigned healer (NOT from KAD), because before chain VERIFIED no copy +// is yet in KAD and the healer is the only authority. This handler exposes +// the verifier-side fetch as a streaming gRPC RPC, gated on caller ∈ +// op.VerifierSupernodeAccounts. +package self_healing + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/LumeraProtocol/supernode/v2/gen/supernode" + "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + "github.com/LumeraProtocol/supernode/v2/pkg/reachability" + cascadeService "github.com/LumeraProtocol/supernode/v2/supernode/cascade" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + // streamChunkBytes is the chunk size used by ServeReconstructedArtefacts. + // Tuned for grpc max message default (4 MiB) — chunks are 1 MiB so + // a 100 MiB file streams in ~100 messages. + streamChunkBytes = 1 << 20 +) + +// CallerIdentityResolver returns the authenticated chain-side supernode +// account address of the gRPC caller. The production resolver pulls it +// from the secure-rpc / lumeraid handshake the storage_challenge handler +// uses (pkg/reachability.GrpcRemoteIdentityAndAddr). +type CallerIdentityResolver func(ctx context.Context) (string, error) + +// DefaultCallerIdentityResolver returns a resolver backed by the secure-rpc +// (Lumera ALTS) handshake. The returned identity is the verifier's +// chain-side supernode account; if the inbound connection is NOT secure-rpc +// the resolver returns an error so the handler refuses to serve. +func DefaultCallerIdentityResolver() CallerIdentityResolver { + return func(ctx context.Context) (string, error) { + identity, _ := reachability.GrpcRemoteIdentityAndAddr(ctx) + identity = strings.TrimSpace(identity) + if identity == "" { + return "", errors.New("caller identity unavailable: secure-rpc / ALTS handshake required") + } + return identity, nil + } +} + +// Server implements supernode.SelfHealingServiceServer for the LEP-6 §19 +// healer-served path. One instance per supernode binary; runs alongside the +// dispatcher Service in self_healing.Service. +type Server struct { + supernode.UnimplementedSelfHealingServiceServer + + identity string + stagingRoot string + lumera lumera.Client + resolveCaller CallerIdentityResolver +} + +// NewServer constructs the §19 transport handler for production use. +// +// resolveCaller authenticates the gRPC peer. It MUST be non-nil: production +// paths use DefaultCallerIdentityResolver(), which pulls the identity from +// the secure-rpc (Lumera ALTS) handshake. For unit tests where no transport +// stack is wired up, use NewServerForTest, which permits a nil resolver and +// falls back to trusting req.VerifierAccount. +func NewServer(identity, stagingRoot string, lumeraClient lumera.Client, resolveCaller CallerIdentityResolver) (*Server, error) { + if resolveCaller == nil { + return nil, fmt.Errorf("resolveCaller is required in production; use NewServerForTest for unit tests") + } + return newServer(identity, stagingRoot, lumeraClient, resolveCaller) +} + +// NewServerForTest is a test-only constructor that permits a nil resolver, +// falling back to trusting req.VerifierAccount. It must NEVER be wired into +// a production binary. +func NewServerForTest(identity, stagingRoot string, lumeraClient lumera.Client, resolveCaller CallerIdentityResolver) (*Server, error) { + return newServer(identity, stagingRoot, lumeraClient, resolveCaller) +} + +func newServer(identity, stagingRoot string, lumeraClient lumera.Client, resolveCaller CallerIdentityResolver) (*Server, error) { + identity = strings.TrimSpace(identity) + if identity == "" { + return nil, fmt.Errorf("identity is empty") + } + if lumeraClient == nil || lumeraClient.Audit() == nil { + return nil, fmt.Errorf("lumera client missing audit module") + } + if strings.TrimSpace(stagingRoot) == "" { + return nil, fmt.Errorf("staging root is empty") + } + return &Server{ + identity: identity, + stagingRoot: stagingRoot, + lumera: lumeraClient, + resolveCaller: resolveCaller, + }, nil +} + +// ServeReconstructedArtefacts streams the reconstructed file bytes for one +// heal-op to an authorized verifier. +// +// Authorization (§19): caller must be a member of +// op.VerifierSupernodeAccounts. Caller account is preferentially read from +// CallerIdentityResolver (authenticated transport identity); req.Verifier +// Account is used only as a fallback for tests where no resolver was +// configured — production paths MUST use DefaultCallerIdentityResolver(). +func (s *Server) ServeReconstructedArtefacts(req *supernode.ServeReconstructedArtefactsRequest, stream supernode.SelfHealingService_ServeReconstructedArtefactsServer) error { + if req == nil || req.HealOpId == 0 { + return status.Error(codes.InvalidArgument, "missing heal_op_id") + } + ctx := stream.Context() + + // Resolve caller identity. If a resolver is configured (production), + // the resolver's verdict wins over req.VerifierAccount — never trust + // the request payload alone. + var caller string + if s.resolveCaller != nil { + auth, err := s.resolveCaller(ctx) + if err != nil { + return status.Errorf(codes.Unauthenticated, "resolve caller: %v", err) + } + caller = strings.TrimSpace(auth) + } else { + caller = strings.TrimSpace(req.VerifierAccount) + } + if caller == "" { + return status.Error(codes.Unauthenticated, "caller identity unknown") + } + + // Authorize against on-chain heal-op. + resp, err := s.lumera.Audit().GetHealOp(ctx, req.HealOpId) + if err != nil { + return status.Errorf(codes.NotFound, "heal op %d: %v", req.HealOpId, err) + } + if resp == nil { + return status.Errorf(codes.NotFound, "heal op %d not found", req.HealOpId) + } + op := resp.HealOp + if op.HealerSupernodeAccount != s.identity { + // Not the assigned healer for this op — refuse to serve so verifiers + // don't accidentally consult a non-authoritative supernode. + return status.Error(codes.FailedPrecondition, "this supernode is not the assigned healer for this heal op") + } + // LEP-6 §19 hardening (H8): serving reconstructed artefacts is only + // valid while the heal-op is in HEALER_REPORTED — i.e. the healer has + // staged the file and verifiers are sampling it. After VERIFIED / FAILED / + // EXPIRED the staging dir is logically dead (and may be on its way to + // finalizer cleanup); mid-stage SCHEDULED has no committed reconstruction. + if op.Status != audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED { + return status.Errorf(codes.FailedPrecondition, + "heal op %d status is %s; serve only valid in HEALER_REPORTED", + op.HealOpId, op.Status) + } + authorized := false + for _, v := range op.VerifierSupernodeAccounts { + if v == caller { + authorized = true + break + } + } + if !authorized { + return status.Errorf(codes.PermissionDenied, "caller %q not in verifier set", caller) + } + + // Resolve staging dir + reconstructed file. + stagingDir := filepath.Join(s.stagingRoot, fmt.Sprintf("%d", req.HealOpId)) + info, err := cascadeService.ReadStagedHealOp(stagingDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return status.Errorf(codes.NotFound, "no staged heal-op %d", req.HealOpId) + } + return status.Errorf(codes.Internal, "read staged heal op: %v", err) + } + + f, err := os.Open(info.ReconstructedFilePath) + if err != nil { + return status.Errorf(codes.Internal, "open staged file: %v", err) + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return status.Errorf(codes.Internal, "stat staged file: %v", err) + } + totalSize := uint64(st.Size()) + + logtrace.Info(ctx, "self_healing(LEP-6): serving reconstructed artefacts", logtrace.Fields{ + "heal_op_id": req.HealOpId, + "caller": caller, + "size": totalSize, + }) + + buf := make([]byte, streamChunkBytes) + first := true + var sent uint64 + for { + n, rerr := f.Read(buf) + if n > 0 { + sent += uint64(n) + out := &supernode.ServeReconstructedArtefactsResponse{ + Chunk: append([]byte(nil), buf[:n]...), + IsLast: false, + } + if first { + out.TotalSize = totalSize + first = false + } + if rerr == io.EOF || sent == totalSize { + out.IsLast = true + } + if err := stream.Send(out); err != nil { + return err + } + } + if rerr == io.EOF { + return nil + } + if rerr != nil { + return status.Errorf(codes.Internal, "read staged file: %v", rerr) + } + } +} diff --git a/supernode/transport/grpc/self_healing/handler_status_gate_test.go b/supernode/transport/grpc/self_healing/handler_status_gate_test.go new file mode 100644 index 00000000..10f55242 --- /dev/null +++ b/supernode/transport/grpc/self_healing/handler_status_gate_test.go @@ -0,0 +1,108 @@ +package self_healing + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + cascadekit "github.com/LumeraProtocol/supernode/v2/pkg/cascadekit" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/gen/supernode" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// statusOverrideAudit lets a test inject a chosen HealOp status while keeping +// the rest of the wiring identical to handlerStubAudit. +type statusOverrideAudit struct { + handlerStubAudit +} + +func newStatusHarness(t *testing.T, healStatus audittypes.HealOpStatus, body []byte) (*Server, func(), uint64) { + t.Helper() + root := filepath.Join(t.TempDir(), "heal-staging") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + hash, err := cascadekit.ComputeBlake3DataHashB64(body) + if err != nil { + t.Fatalf("hash: %v", err) + } + const healOpID = 555 + dir := makeStagingDir(t, root, healOpID, hash, body) + + a := &statusOverrideAudit{handlerStubAudit: handlerStubAudit{op: audittypes.HealOp{ + HealOpId: healOpID, + HealerSupernodeAccount: "sn-healer", + VerifierSupernodeAccounts: []string{"sn-v1"}, + Status: healStatus, + ResultHash: hash, + }}} + srv, err := NewServerForTest("sn-healer", root, &handlerLumera{audit: a}, nil) + if err != nil { + t.Fatalf("NewServerForTest: %v", err) + } + return srv, func() { _ = os.RemoveAll(dir) }, healOpID +} + +// TestServeReconstructedArtefacts_StatusGate verifies H8: serve is only valid +// while op.Status == HEALER_REPORTED. All other statuses must be rejected +// with FailedPrecondition. +func TestServeReconstructedArtefacts_StatusGate(t *testing.T) { + body := []byte("payload") + disallowed := []audittypes.HealOpStatus{ + audittypes.HealOpStatus_HEAL_OP_STATUS_UNSPECIFIED, + audittypes.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + audittypes.HealOpStatus_HEAL_OP_STATUS_VERIFIED, + audittypes.HealOpStatus_HEAL_OP_STATUS_FAILED, + audittypes.HealOpStatus_HEAL_OP_STATUS_EXPIRED, + } + for _, st := range disallowed { + st := st + t.Run(st.String(), func(t *testing.T) { + srv, cleanup, opID := newStatusHarness(t, st, body) + defer cleanup() + _, err := callServe(t, srv, &supernode.ServeReconstructedArtefactsRequest{ + HealOpId: opID, + VerifierAccount: "sn-v1", + }) + if err == nil { + t.Fatalf("status %s must be rejected", st) + } + s, _ := status.FromError(err) + if s.Code() != codes.FailedPrecondition { + t.Fatalf("status %s: expected FailedPrecondition, got %v: %v", st, s.Code(), err) + } + }) + } + + // Sanity: HEALER_REPORTED still works. + srv, cleanup, opID := newStatusHarness(t, audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, body) + defer cleanup() + got, err := callServe(t, srv, &supernode.ServeReconstructedArtefactsRequest{ + HealOpId: opID, + VerifierAccount: "sn-v1", + }) + if err != nil { + t.Fatalf("HEALER_REPORTED must succeed: %v", err) + } + if !bytes.Equal(got, body) { + t.Fatalf("body mismatch: got %q, want %q", got, body) + } +} + +// TestNewServer_RejectsNilResolver verifies L1: production constructor +// rejects a nil resolveCaller — only NewServerForTest may accept nil. +func TestNewServer_RejectsNilResolver(t *testing.T) { + a := &handlerStubAudit{op: audittypes.HealOp{HealOpId: 1}} + _, err := NewServer("sn-x", t.TempDir(), &handlerLumera{audit: a}, nil) + if err == nil { + t.Fatalf("expected error from NewServer with nil resolver") + } + + // Test-only constructor must still accept nil (it's the documented escape hatch). + if _, err := NewServerForTest("sn-x", t.TempDir(), &handlerLumera{audit: a}, nil); err != nil { + t.Fatalf("NewServerForTest must accept nil: %v", err) + } +} diff --git a/supernode/transport/grpc/self_healing/handler_test.go b/supernode/transport/grpc/self_healing/handler_test.go new file mode 100644 index 00000000..f6feedbb --- /dev/null +++ b/supernode/transport/grpc/self_healing/handler_test.go @@ -0,0 +1,280 @@ +package self_healing + +import ( + "context" + "errors" + "io" + "net" + "os" + "path/filepath" + "sync" + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/gen/supernode" + "github.com/LumeraProtocol/supernode/v2/pkg/cascadekit" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/action" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/action_msg" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/audit" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/audit_msg" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/auth" + bankmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/bank" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/node" + supernodeMod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/supernode" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/supernode_msg" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/tx" + "github.com/LumeraProtocol/supernode/v2/pkg/testutil" + query "github.com/cosmos/cosmos-sdk/types/query" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// --------------------------------------------------------------------------- +// Test 4 — TestServeReconstructedArtefacts_AuthorizesOnlyAssignedVerifiers. +// --------------------------------------------------------------------------- +func TestServeReconstructedArtefacts_AuthorizesOnlyAssignedVerifiers(t *testing.T) { + srv, cleanup, _ := newHandlerHarness(t, "sn-healer", &handlerOp{ + HealOpId: 100, + HealerSupernodeAccount: "sn-healer", + VerifierSupernodeAccounts: []string{"sn-v1", "sn-v2"}, + }, []byte("payload-bytes")) + defer cleanup() + + body, err := callServe(t, srv, &supernode.ServeReconstructedArtefactsRequest{ + HealOpId: 100, + VerifierAccount: "sn-v1", + }) + if err != nil { + t.Fatalf("authorized verifier should succeed: %v", err) + } + if string(body) != "payload-bytes" { + t.Fatalf("unexpected body: %q", string(body)) + } +} + +// --------------------------------------------------------------------------- +// Test 5 — TestServeReconstructedArtefacts_RejectsUnassignedCaller. +// --------------------------------------------------------------------------- +func TestServeReconstructedArtefacts_RejectsUnassignedCaller(t *testing.T) { + srv, cleanup, _ := newHandlerHarness(t, "sn-healer", &handlerOp{ + HealOpId: 101, + HealerSupernodeAccount: "sn-healer", + VerifierSupernodeAccounts: []string{"sn-v1", "sn-v2"}, + }, []byte("p")) + defer cleanup() + + _, err := callServe(t, srv, &supernode.ServeReconstructedArtefactsRequest{ + HealOpId: 101, + VerifierAccount: "sn-attacker", + }) + if err == nil { + t.Fatalf("unauthorized caller must be rejected") + } + st, _ := status.FromError(err) + if st.Code() != codes.PermissionDenied { + t.Fatalf("expected PermissionDenied, got %v: %v", st.Code(), err) + } + + // Also: a different supernode that isn't even the assigned healer should + // refuse to serve regardless of caller. + wrongHealerSrv, wrongCleanup, _ := newHandlerHarness(t, "sn-not-healer", &handlerOp{ + HealOpId: 102, + HealerSupernodeAccount: "sn-real-healer", + VerifierSupernodeAccounts: []string{"sn-v1"}, + }, []byte("p")) + defer wrongCleanup() + _, err = callServe(t, wrongHealerSrv, &supernode.ServeReconstructedArtefactsRequest{ + HealOpId: 102, + VerifierAccount: "sn-v1", + }) + if err == nil { + t.Fatalf("non-assigned-healer must refuse to serve") + } + st, _ = status.FromError(err) + if st.Code() != codes.FailedPrecondition { + t.Fatalf("expected FailedPrecondition, got %v: %v", st.Code(), err) + } +} + +// --------------------------------------------------------------------------- +// handler harness +// --------------------------------------------------------------------------- + +type handlerOp struct { + HealOpId uint64 + HealerSupernodeAccount string + VerifierSupernodeAccounts []string +} + +func newHandlerHarness(t *testing.T, identity string, op *handlerOp, body []byte) (*Server, func(), string) { + t.Helper() + root := filepath.Join(t.TempDir(), "heal-staging") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + hash, err := cascadekit.ComputeBlake3DataHashB64(body) + if err != nil { + t.Fatalf("hash: %v", err) + } + dir := makeStagingDir(t, root, op.HealOpId, hash, body) + + a := &handlerStubAudit{op: audittypes.HealOp{ + HealOpId: op.HealOpId, + HealerSupernodeAccount: op.HealerSupernodeAccount, + VerifierSupernodeAccounts: op.VerifierSupernodeAccounts, + Status: audittypes.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + ResultHash: hash, + }} + srv, err := NewServerForTest(identity, root, &handlerLumera{audit: a}, nil) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + cleanup := func() { _ = os.RemoveAll(dir) } + return srv, cleanup, hash +} + +// callServe dials the server through bufconn and consumes the stream. +func callServe(t *testing.T, srv *Server, req *supernode.ServeReconstructedArtefactsRequest) ([]byte, error) { + t.Helper() + listener := bufconn.Listen(1 << 16) + gs := grpc.NewServer() + supernode.RegisterSelfHealingServiceServer(gs, srv) + go func() { _ = gs.Serve(listener) }() + defer gs.Stop() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + conn, err := grpc.DialContext(ctx, "bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return listener.DialContext(ctx) }), + grpc.WithInsecure(), + ) + if err != nil { + return nil, err + } + defer conn.Close() + c := supernode.NewSelfHealingServiceClient(conn) + stream, err := c.ServeReconstructedArtefacts(ctx, req) + if err != nil { + return nil, err + } + var buf []byte + for { + msg, err := stream.Recv() + if errors.Is(err, io.EOF) { + return buf, nil + } + if err != nil { + return nil, err + } + buf = append(buf, msg.Chunk...) + if msg.IsLast { + // Drain to surface a trailing status (if any). + _, _ = stream.Recv() + return buf, nil + } + } +} + +// handlerLumera is a minimal lumera.Client for the transport handler tests +// — only Audit() is consulted. +type handlerLumera struct { + mu sync.Mutex + audit audit.Module + stubsRef lumera.Client +} + +func (h *handlerLumera) Auth() auth.Module { + h.ensureStubs() + return h.stubsRef.Auth() +} +func (h *handlerLumera) Action() action.Module { + h.ensureStubs() + return h.stubsRef.Action() +} +func (h *handlerLumera) ActionMsg() action_msg.Module { + h.ensureStubs() + return h.stubsRef.ActionMsg() +} +func (h *handlerLumera) Audit() audit.Module { return h.audit } +func (h *handlerLumera) AuditMsg() audit_msg.Module { return h.stubsRef.AuditMsg() } +func (h *handlerLumera) SuperNode() supernodeMod.Module { + h.ensureStubs() + return h.stubsRef.SuperNode() +} +func (h *handlerLumera) SuperNodeMsg() supernode_msg.Module { + h.ensureStubs() + return h.stubsRef.SuperNodeMsg() +} +func (h *handlerLumera) Bank() bankmod.Module { + h.ensureStubs() + return h.stubsRef.Bank() +} +func (h *handlerLumera) Tx() tx.Module { + h.ensureStubs() + return h.stubsRef.Tx() +} +func (h *handlerLumera) Node() node.Module { + h.ensureStubs() + return h.stubsRef.Node() +} +func (h *handlerLumera) Close() error { return nil } + +func (h *handlerLumera) ensureStubs() { + h.mu.Lock() + defer h.mu.Unlock() + if h.stubsRef == nil { + c, err := testutil.NewMockLumeraClient(nil, nil) + if err != nil { + panic(err) + } + h.stubsRef = c + } +} + +type handlerStubAudit struct{ op audittypes.HealOp } + +func (h *handlerStubAudit) GetParams(ctx context.Context) (*audittypes.QueryParamsResponse, error) { + return &audittypes.QueryParamsResponse{}, nil +} +func (h *handlerStubAudit) GetEpochAnchor(ctx context.Context, epochID uint64) (*audittypes.QueryEpochAnchorResponse, error) { + return &audittypes.QueryEpochAnchorResponse{}, nil +} +func (h *handlerStubAudit) GetCurrentEpochAnchor(ctx context.Context) (*audittypes.QueryCurrentEpochAnchorResponse, error) { + return &audittypes.QueryCurrentEpochAnchorResponse{}, nil +} +func (h *handlerStubAudit) GetCurrentEpoch(ctx context.Context) (*audittypes.QueryCurrentEpochResponse, error) { + return &audittypes.QueryCurrentEpochResponse{}, nil +} +func (h *handlerStubAudit) GetAssignedTargets(ctx context.Context, supernodeAccount string, epochID uint64) (*audittypes.QueryAssignedTargetsResponse, error) { + return &audittypes.QueryAssignedTargetsResponse{}, nil +} +func (h *handlerStubAudit) GetEpochReport(ctx context.Context, epochID uint64, supernodeAccount string) (*audittypes.QueryEpochReportResponse, error) { + return &audittypes.QueryEpochReportResponse{}, nil +} +func (h *handlerStubAudit) GetEpochReportsByReporter(ctx context.Context, reporterAccount string, epochID uint64) (*audittypes.QueryEpochReportsByReporterResponse, error) { + return &audittypes.QueryEpochReportsByReporterResponse{}, nil +} +func (h *handlerStubAudit) GetNodeSuspicionState(ctx context.Context, supernodeAccount string) (*audittypes.QueryNodeSuspicionStateResponse, error) { + return &audittypes.QueryNodeSuspicionStateResponse{}, nil +} +func (h *handlerStubAudit) GetReporterReliabilityState(ctx context.Context, reporterAccount string) (*audittypes.QueryReporterReliabilityStateResponse, error) { + return &audittypes.QueryReporterReliabilityStateResponse{}, nil +} +func (h *handlerStubAudit) GetTicketDeteriorationState(ctx context.Context, ticketID string) (*audittypes.QueryTicketDeteriorationStateResponse, error) { + return &audittypes.QueryTicketDeteriorationStateResponse{}, nil +} +func (h *handlerStubAudit) GetHealOp(ctx context.Context, healOpID uint64) (*audittypes.QueryHealOpResponse, error) { + if healOpID != h.op.HealOpId { + return nil, errors.New("not found") + } + return &audittypes.QueryHealOpResponse{HealOp: h.op}, nil +} +func (h *handlerStubAudit) GetHealOpsByStatus(ctx context.Context, status audittypes.HealOpStatus, pagination *query.PageRequest) (*audittypes.QueryHealOpsByStatusResponse, error) { + return &audittypes.QueryHealOpsByStatusResponse{}, nil +} +func (h *handlerStubAudit) GetHealOpsByTicket(ctx context.Context, ticketID string, pagination *query.PageRequest) (*audittypes.QueryHealOpsByTicketResponse, error) { + return &audittypes.QueryHealOpsByTicketResponse{}, nil +} diff --git a/supernode/transport/grpc/self_healing/helpers_test.go b/supernode/transport/grpc/self_healing/helpers_test.go new file mode 100644 index 00000000..0d1ac770 --- /dev/null +++ b/supernode/transport/grpc/self_healing/helpers_test.go @@ -0,0 +1,38 @@ +package self_healing + +import ( + "os" + "path/filepath" + "testing" +) + +// makeStagingDir creates a minimal heal-op staging dir matching the layout +// produced by cascade.stageArtefacts: manifest.json + reconstructed.bin + +// empty symbols/ subdir. Returns the absolute staging dir path. +func makeStagingDir(t *testing.T, root string, opID uint64, hashB64 string, body []byte) string { + t.Helper() + dir := filepath.Join(root, itoa(opID)) + if err := os.MkdirAll(filepath.Join(dir, "symbols"), 0o700); err != nil { + t.Fatalf("mkdir staging: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "reconstructed.bin"), body, 0o600); err != nil { + t.Fatalf("write reconstructed: %v", err) + } + manifest := []byte(`{"action_id":"ticket-` + itoa(opID) + `","layout":{"blocks":[]},"id_files":[],"symbol_keys":[],"symbols_dir":"` + filepath.Join(dir, "symbols") + `","reconstructed_rel":"reconstructed.bin","manifest_hash_b64":"` + hashB64 + `"}`) + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), manifest, 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + return dir +} + +func itoa(u uint64) string { + if u == 0 { + return "0" + } + digits := []byte{} + for u > 0 { + digits = append([]byte{byte('0' + u%10)}, digits...) + u /= 10 + } + return string(digits) +} diff --git a/supernode/transport/grpc/storage_challenge/handler.go b/supernode/transport/grpc/storage_challenge/handler.go index 34d4d5ba..6d4c9c3f 100644 --- a/supernode/transport/grpc/storage_challenge/handler.go +++ b/supernode/transport/grpc/storage_challenge/handler.go @@ -6,17 +6,43 @@ import ( "encoding/json" "fmt" "strings" + "sync" "time" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" "github.com/LumeraProtocol/supernode/v2/gen/supernode" "github.com/LumeraProtocol/supernode/v2/p2p" + snkeyring "github.com/LumeraProtocol/supernode/v2/pkg/keyring" "github.com/LumeraProtocol/supernode/v2/pkg/logtrace" "github.com/LumeraProtocol/supernode/v2/pkg/storage/queries" + "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge/deterministic" "github.com/LumeraProtocol/supernode/v2/pkg/types" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "lukechampine.com/blake3" ) -const maxServedSliceBytes = uint64(65_536) +const ( + maxServedSliceBytes = uint64(65_536) + compoundCapsTTL = time.Minute +) + +// ArtifactReader is the recipient-side abstraction over cascade artifact storage +// used to satisfy LEP-6 multi-range compound storage challenges. The B.3 wiring +// will provide a cascade-module-backed implementation; tests inject their own. +type ArtifactReader interface { + ReadArtifactRange(ctx context.Context, class audittypes.StorageProofArtifactClass, key string, start, end uint64) ([]byte, error) +} + +type AuditParamReader interface { + GetParams(ctx context.Context) (*audittypes.QueryParamsResponse, error) +} + +type compoundCaps struct { + maxRanges uint32 + maxLen uint32 +} type Server struct { supernode.UnimplementedStorageChallengeServiceServer @@ -24,12 +50,88 @@ type Server struct { identity string p2p p2p.Client store queries.LocalStoreInterface + reader ArtifactReader + + // keyring + keyName are used to sign LEP-6 GetCompoundProof responses + // (recipient_signature) over the response transcript hash. Both may + // remain unset for legacy / test paths; signing is then skipped and + // recipient_signature stays empty. + keyring keyring.Keyring + keyName string + + auditParams AuditParamReader + capsMu sync.RWMutex + caps compoundCaps + capsUntil time.Time } func NewServer(identity string, p2pClient p2p.Client, store queries.LocalStoreInterface) *Server { return &Server{identity: identity, p2p: p2pClient, store: store} } +// WithArtifactReader configures the server with the LEP-6 compound-challenge +// recipient-side reader. Returns the receiver for chained construction. +func (s *Server) WithArtifactReader(reader ArtifactReader) *Server { + s.reader = reader + return s +} + +// WithRecipientSigner configures the keyring + key name used to sign +// LEP-6 GetCompoundProof response transcripts. Returns the receiver for +// chained construction. +func (s *Server) WithRecipientSigner(kr keyring.Keyring, keyName string) *Server { + s.keyring = kr + s.keyName = keyName + return s +} + +func (s *Server) WithAuditParams(audit AuditParamReader) *Server { + s.auditParams = audit + return s +} + +func (s *Server) WithCompoundCapsForTest(maxRanges, maxLen uint32) *Server { + s.capsMu.Lock() + defer s.capsMu.Unlock() + s.caps = compoundCaps{maxRanges: maxRanges, maxLen: maxLen} + s.capsUntil = time.Now().Add(24 * time.Hour) + return s +} + +func (s *Server) compoundCaps(ctx context.Context) compoundCaps { + fallback := compoundCaps{maxRanges: audittypes.DefaultStorageTruthCompoundRangesPerArtifact, maxLen: audittypes.DefaultStorageTruthCompoundRangeLenBytes} + now := time.Now() + s.capsMu.RLock() + cached, valid := s.caps, now.Before(s.capsUntil) + s.capsMu.RUnlock() + if valid && cached.maxRanges > 0 && cached.maxLen > 0 { + return cached + } + if s.auditParams == nil { + return fallback + } + resp, err := s.auditParams.GetParams(ctx) + if err != nil || resp == nil { + logtrace.Warn(ctx, "storage challenge: failed to refresh chain compound caps; using fallback", logtrace.Fields{"error": fmt.Sprint(err)}) + return fallback + } + caps := compoundCaps{ + maxRanges: resp.Params.StorageTruthCompoundRangesPerArtifact, + maxLen: resp.Params.StorageTruthCompoundRangeLenBytes, + } + if caps.maxRanges == 0 { + caps.maxRanges = audittypes.DefaultStorageTruthCompoundRangesPerArtifact + } + if caps.maxLen == 0 { + caps.maxLen = audittypes.DefaultStorageTruthCompoundRangeLenBytes + } + s.capsMu.Lock() + s.caps = caps + s.capsUntil = now.Add(compoundCapsTTL) + s.capsMu.Unlock() + return caps +} + func (s *Server) GetSliceProof(ctx context.Context, req *supernode.GetSliceProofRequest) (*supernode.GetSliceProofResponse, error) { if req == nil { return nil, fmt.Errorf("nil request") @@ -238,3 +340,147 @@ func (s *Server) persistObserverVerification(ctx context.Context, req *supernode "ok": resp.Ok, }) } + +// GetCompoundProof serves a LEP-6 multi-range compound storage challenge. +// The challenger derives range count and range size from chain params; the +// recipient therefore validates only request-level structural invariants rather +// than re-asserting local compile-time defaults. It reads the requested ranges +// via the injected ArtifactReader, computes a BLAKE3 hash over the +// concatenation, and returns range_bytes alongside the proof hash. +func (s *Server) GetCompoundProof(ctx context.Context, req *supernode.GetCompoundProofRequest) (*supernode.GetCompoundProofResponse, error) { + if req == nil { + return nil, fmt.Errorf("nil request") + } + + resp := &supernode.GetCompoundProofResponse{ + ChallengeId: req.ChallengeId, + EpochId: req.EpochId, + TicketId: req.TicketId, + ArtifactClass: req.ArtifactClass, + ArtifactOrdinal: req.ArtifactOrdinal, + BucketType: req.BucketType, + ArtifactKey: req.ArtifactKey, + } + + if req.ChallengeId == "" { + resp.Error = "challenge_id is required" + return resp, nil + } + if req.EpochId == 0 { + resp.Error = "epoch_id must be > 0" + return resp, nil + } + if req.TicketId == "" { + resp.Error = "ticket_id is required" + return resp, nil + } + if len(req.Ranges) == 0 { + resp.Error = "at least one range is required" + return resp, nil + } + caps := s.compoundCaps(ctx) + if len(req.Ranges) > int(caps.maxRanges) { + return nil, status.Errorf(codes.InvalidArgument, + "too many ranges: got %d, max %d", len(req.Ranges), caps.maxRanges) + } + var requestRangeLen uint64 + for i, rng := range req.Ranges { + if rng == nil { + resp.Error = fmt.Sprintf("range[%d] is nil", i) + return resp, nil + } + if rng.End <= rng.Start { + resp.Error = fmt.Sprintf("range[%d] invalid: end (%d) must be > start (%d)", i, rng.End, rng.Start) + return resp, nil + } + size := rng.End - rng.Start + if i == 0 { + requestRangeLen = size + } else if size != requestRangeLen { + resp.Error = fmt.Sprintf("range[%d] invalid size: got %d, want %d from first range", i, size, requestRangeLen) + return resp, nil + } + if rng.End > req.ArtifactSize { + resp.Error = fmt.Sprintf("range[%d] out of bounds: end (%d) > artifact_size (%d)", i, rng.End, req.ArtifactSize) + return resp, nil + } + } + if requestRangeLen > uint64(caps.maxLen) { + return nil, status.Errorf(codes.InvalidArgument, + "range length %d exceeds cap %d", requestRangeLen, caps.maxLen) + } + aggregate := requestRangeLen * uint64(len(req.Ranges)) + aggregateCap := uint64(caps.maxRanges) * uint64(caps.maxLen) + if aggregateCap > uint64(deterministic.MaxCompoundAggregateBytes) { + aggregateCap = uint64(deterministic.MaxCompoundAggregateBytes) + } + if aggregate > aggregateCap { + return nil, status.Errorf(codes.InvalidArgument, + "aggregate range bytes %d exceeds cap %d", aggregate, aggregateCap) + } + + if s.reader == nil { + resp.Error = "artifact reader not configured" + return resp, nil + } + + class := audittypes.StorageProofArtifactClass(req.ArtifactClass) + rangeBytes := make([][]byte, 0, len(req.Ranges)) + hasher := blake3.New(32, nil) + for i, rng := range req.Ranges { + buf, err := s.reader.ReadArtifactRange(ctx, class, req.ArtifactKey, rng.Start, rng.End) + if err != nil { + resp.Error = fmt.Sprintf("read range[%d] [%d,%d): %v", i, rng.Start, rng.End, err) + return resp, nil + } + rangeBytes = append(rangeBytes, buf) + _, _ = hasher.Write(buf) + } + sum := hasher.Sum(nil) + resp.RangeBytes = rangeBytes + resp.ProofHashHex = hex.EncodeToString(sum) + + // Sign the response transcript with the recipient's keyring identity. + // The transcript composition mirrors the challenger-side TranscriptHash + // composition (deterministic.TranscriptInputs) so the off-chain + // reporter can attach this signature to its StorageProofResult and + // the chain (post-LEP-6) can verify both endpoints corroborate the + // proof. Recipient acts here as the TARGET supernode. + if s.keyring != nil && strings.TrimSpace(s.keyName) != "" { + obs := append([]string(nil), req.ObserverAccounts...) + offsets := make([]uint64, 0, len(req.Ranges)) + for _, rng := range req.Ranges { + offsets = append(offsets, rng.Start) + } + derivHash, hashErr := deterministic.DerivationInputHash(req.Seed, req.TargetSupernodeAccount, req.TicketId, class, req.ArtifactOrdinal, offsets, requestRangeLen) + if hashErr != nil { + resp.Error = fmt.Sprintf("derivation input hash: %v", hashErr) + return resp, nil + } + txHash, hashErr := deterministic.TranscriptHash(deterministic.TranscriptInputs{ + EpochID: req.EpochId, + ChallengerSupernodeAccount: req.ChallengerAccount, + TargetSupernodeAccount: req.TargetSupernodeAccount, + TicketID: req.TicketId, + Bucket: audittypes.StorageProofBucketType(req.BucketType), + ArtifactClass: class, + ArtifactOrdinal: req.ArtifactOrdinal, + ArtifactKey: req.ArtifactKey, + DerivationInputHash: derivHash, + CompoundProofHashHex: resp.ProofHashHex, + ObserverIDs: obs, + }) + if hashErr != nil { + resp.Error = fmt.Sprintf("transcript hash: %v", hashErr) + return resp, nil + } + sig, signErr := snkeyring.SignBytes(s.keyring, s.keyName, []byte(txHash)) + if signErr != nil { + resp.Error = fmt.Sprintf("recipient sign: %v", signErr) + return resp, nil + } + resp.RecipientSignature = hex.EncodeToString(sig) + } + resp.Ok = true + return resp, nil +} diff --git a/supernode/transport/grpc/storage_challenge/handler_compound_caps_test.go b/supernode/transport/grpc/storage_challenge/handler_compound_caps_test.go new file mode 100644 index 00000000..69ab2def --- /dev/null +++ b/supernode/transport/grpc/storage_challenge/handler_compound_caps_test.go @@ -0,0 +1,167 @@ +package storage_challenge + +import ( + "context" + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/gen/supernode" + "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge/deterministic" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// TestGetCompoundProof_RangeCountCap verifies C6: requests with more than +// MaxCompoundRanges ranges are rejected with InvalidArgument before any +// artifact bytes are read. +func TestGetCompoundProof_RangeCountCap(t *testing.T) { + t.Parallel() + + reader := &deterministicReader{} + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(reader) + + rl := uint64(deterministic.LEP6CompoundRangeLenBytes) + ranges := make([]*supernode.ByteRange, 0, deterministic.MaxCompoundRanges+1) + for i := uint64(0); i < uint64(deterministic.MaxCompoundRanges)+1; i++ { + ranges = append(ranges, &supernode.ByteRange{Start: i * 1024, End: i*1024 + rl}) + } + req := compoundRequestWith(ranges, 1<<20) + + resp, err := srv.GetCompoundProof(context.Background(), req) + require.Error(t, err) + require.Nil(t, resp) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.InvalidArgument, st.Code()) + require.Contains(t, st.Message(), "too many ranges") + require.Equal(t, 0, reader.calls, "no bytes should be read on cap rejection") +} + +// TestGetCompoundProof_RangeLengthCap verifies C6: a single range whose +// length exceeds MaxCompoundRangeLenBytes is rejected. +func TestGetCompoundProof_RangeLengthCap(t *testing.T) { + t.Parallel() + + reader := &deterministicReader{} + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(reader) + + overlong := uint64(deterministic.MaxCompoundRangeLenBytes) + 1 + req := compoundRequestWith([]*supernode.ByteRange{ + {Start: 0, End: overlong}, + }, 1<<20) + + resp, err := srv.GetCompoundProof(context.Background(), req) + require.Error(t, err) + require.Nil(t, resp) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.InvalidArgument, st.Code()) + require.Contains(t, st.Message(), "range length") + require.Equal(t, 0, reader.calls) +} + +// TestGetCompoundProof_AggregateBytesCap_DefenseInDepth verifies C6: the +// aggregate-bytes cap is wired and matches MaxCompoundAggregateBytes. With +// the current constants (16 × 1024 = 16384 = MaxCompoundAggregateBytes), the +// individual count/length caps fire first; this test acts as a regression +// guard against constants drifting in a way that lets aggregate exceed the +// declared maximum without per-call rejection. +func TestGetCompoundProof_AggregateBytesCap_DefenseInDepth(t *testing.T) { + t.Parallel() + require.LessOrEqualf(t, + uint64(deterministic.MaxCompoundRanges)*uint64(deterministic.MaxCompoundRangeLenBytes), + uint64(deterministic.MaxCompoundAggregateBytes), + "per-range and per-count caps must not permit a request that exceeds MaxCompoundAggregateBytes") +} + +// TestGetCompoundProof_AggregateAtExactCap verifies C6: aggregate exactly +// equal to the cap is accepted (boundary). +func TestGetCompoundProof_AggregateAtExactCap(t *testing.T) { + t.Parallel() + + reader := &deterministicReader{} + srv := NewServer("recipient-1", &testP2PClient{}, nil). + WithArtifactReader(reader). + WithCompoundCapsForTest(uint32(deterministic.MaxCompoundRanges), uint32(deterministic.MaxCompoundRangeLenBytes)) + + // 16 ranges × 1024 bytes/range = 16384 bytes = MaxCompoundAggregateBytes exactly. + rl := uint64(deterministic.MaxCompoundAggregateBytes / deterministic.MaxCompoundRanges) + require.LessOrEqual(t, rl, uint64(deterministic.MaxCompoundRangeLenBytes)) + ranges := make([]*supernode.ByteRange, 0, deterministic.MaxCompoundRanges) + for i := uint64(0); i < uint64(deterministic.MaxCompoundRanges); i++ { + ranges = append(ranges, &supernode.ByteRange{Start: i * 4096, End: i*4096 + rl}) + } + req := compoundRequestWith(ranges, 1<<20) + + resp, err := srv.GetCompoundProof(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.Ok, "error: %s", resp.Error) + require.Equal(t, deterministic.MaxCompoundRanges, reader.calls) +} + +func TestGetCompoundProofHonorsChainParamCaps(t *testing.T) { + srv := NewServer("recipient-1", &testP2PClient{}, nil). + WithArtifactReader(&deterministicReader{}). + WithCompoundCapsForTest(4, 256) + req := validCompoundRequestForCaps(5, 128) + _, err := srv.GetCompoundProof(context.Background(), req) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("expected InvalidArgument for 5 ranges over chain cap 4, got %v", err) + } + + req = validCompoundRequestForCaps(4, 257) + _, err = srv.GetCompoundProof(context.Background(), req) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("expected InvalidArgument for len 257 over chain cap 256, got %v", err) + } +} + +func validCompoundRequestForCaps(n int, size uint64) *supernode.GetCompoundProofRequest { + ranges := make([]*supernode.ByteRange, 0, n) + for i := 0; i < n; i++ { + start := uint64(i) * size + ranges = append(ranges, &supernode.ByteRange{Start: start, End: start + size}) + } + return &supernode.GetCompoundProofRequest{ + ChallengeId: "challenge-caps", + EpochId: 7, + TicketId: "ticket-caps", + ArtifactClass: uint32(audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL), + ArtifactKey: "artifact-caps", + ArtifactSize: uint64(n)*size + 1, + BucketType: uint32(audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT), + ArtifactOrdinal: 0, + ArtifactCount: 1, + Ranges: ranges, + } +} + +func TestGetCompoundProof_AggregateBytesCapBranch(t *testing.T) { + t.Parallel() + + reader := &deterministicReader{} + srv := NewServer("recipient-1", &testP2PClient{}, nil). + WithArtifactReader(reader). + WithCompoundCapsForTest(20, 1024) + + // Each range is within the chain-param count/length caps, but the total + // payload exceeds MaxCompoundAggregateBytes so the aggregate guard itself + // must reject before any artifact bytes are read. + rangeLen := uint64(1000) + ranges := make([]*supernode.ByteRange, 0, 17) + for i := uint64(0); i < 17; i++ { + ranges = append(ranges, &supernode.ByteRange{Start: i * (1 << 20), End: i*(1<<20) + rangeLen}) + } + req := compoundRequestWith(ranges, 1<<30) + + resp, err := srv.GetCompoundProof(context.Background(), req) + require.Error(t, err) + require.Nil(t, resp) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.InvalidArgument, st.Code()) + require.Contains(t, st.Message(), "aggregate") + require.Equal(t, 0, reader.calls) +} diff --git a/supernode/transport/grpc/storage_challenge/handler_compound_test.go b/supernode/transport/grpc/storage_challenge/handler_compound_test.go new file mode 100644 index 00000000..c925e4b5 --- /dev/null +++ b/supernode/transport/grpc/storage_challenge/handler_compound_test.go @@ -0,0 +1,279 @@ +package storage_challenge + +import ( + "context" + "encoding/binary" + "encoding/hex" + "io" + "strings" + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/gen/supernode" + "github.com/LumeraProtocol/supernode/v2/pkg/storagechallenge/deterministic" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + "github.com/cosmos/cosmos-sdk/crypto/hd" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + "github.com/cosmos/go-bip39" + "github.com/stretchr/testify/require" + "lukechampine.com/blake3" +) + +// deterministicReader produces reproducible bytes derived from +// (class, key, start, end) so tests can assert exact proof hashes. +type deterministicReader struct { + calls int + err error +} + +func (r *deterministicReader) ReadArtifactRange(_ context.Context, class audittypes.StorageProofArtifactClass, key string, start, end uint64) ([]byte, error) { + r.calls++ + if r.err != nil { + return nil, r.err + } + out := make([]byte, end-start) + seed := make([]byte, 0, 32+len(key)) + var sb [4]byte + binary.BigEndian.PutUint32(sb[:], uint32(class)) + seed = append(seed, sb[:]...) + seed = append(seed, []byte(key)...) + var ab [16]byte + binary.BigEndian.PutUint64(ab[0:8], start) + binary.BigEndian.PutUint64(ab[8:16], end) + seed = append(seed, ab[:]...) + h := blake3.New(int(end-start), nil) + _, _ = h.Write(seed) + copy(out, h.Sum(nil)) + return out, nil +} + +func compoundRequestWith(ranges []*supernode.ByteRange, artifactSize uint64) *supernode.GetCompoundProofRequest { + return &supernode.GetCompoundProofRequest{ + ChallengeId: "challenge-c1", + EpochId: 42, + TicketId: "ticket-1", + TargetSupernodeAccount: "sn-target", + ChallengerAccount: "sn-challenger", + ObserverAccounts: []string{"o1", "o2"}, + ArtifactClass: uint32(audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL), + ArtifactOrdinal: 3, + ArtifactCount: 8, + BucketType: uint32(audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT), + ArtifactKey: "artifact-key-1", + ArtifactSize: artifactSize, + Ranges: ranges, + } +} + +func fourValidRanges() []*supernode.ByteRange { + rl := uint64(deterministic.LEP6CompoundRangeLenBytes) + return []*supernode.ByteRange{ + {Start: 0, End: rl}, + {Start: 1024, End: 1024 + rl}, + {Start: 4096, End: 4096 + rl}, + {Start: 8192, End: 8192 + rl}, + } +} + +func newCompoundProofKeyring(t *testing.T) (keyring.Keyring, string) { + t.Helper() + ir := codectypes.NewInterfaceRegistry() + cryptocodec.RegisterInterfaces(ir) + cdc := codec.NewProtoCodec(ir) + kr := keyring.NewInMemory(cdc) + entropy, err := bip39.NewEntropy(128) + require.NoError(t, err) + mnemonic, err := bip39.NewMnemonic(entropy) + require.NoError(t, err) + algos, _ := kr.SupportedAlgorithms() + algo, err := keyring.NewSigningAlgoFromString("secp256k1", algos) + require.NoError(t, err) + _, err = kr.NewAccount("recipient-test", mnemonic, "", hd.CreateHDPath(118, 0, 0).String(), algo) + require.NoError(t, err) + return kr, "recipient-test" +} + +func TestGetCompoundProof_HappyPath(t *testing.T) { + t.Parallel() + + reader := &deterministicReader{} + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(reader) + + req := compoundRequestWith(fourValidRanges(), 1<<20) + resp, err := srv.GetCompoundProof(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.Ok, "error: %s", resp.Error) + require.Empty(t, resp.Error) + require.Len(t, resp.RangeBytes, deterministic.LEP6CompoundRangesPerArtifact) + for i, b := range resp.RangeBytes { + require.Lenf(t, b, deterministic.LEP6CompoundRangeLenBytes, "range[%d]", i) + } + require.Equal(t, deterministic.LEP6CompoundRangesPerArtifact, reader.calls) + + // Recompute expected hash via the same deterministic reader to compare. + h := blake3.New(32, nil) + for _, b := range resp.RangeBytes { + _, _ = h.Write(b) + } + require.Equal(t, hex.EncodeToString(h.Sum(nil)), resp.ProofHashHex) + require.Empty(t, resp.RecipientSignature, "recipient signature deferred to B.3") + require.Equal(t, req.ChallengeId, resp.ChallengeId) + require.Equal(t, req.TicketId, resp.TicketId) + require.Equal(t, req.ArtifactKey, resp.ArtifactKey) +} + +func TestGetCompoundProof_RecipientSignatureUsesDerivationInputHash(t *testing.T) { + reader := &deterministicReader{} + kr, keyName := newCompoundProofKeyring(t) + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(reader).WithRecipientSigner(kr, keyName) + + req := compoundRequestWith(fourValidRanges(), 1<<20) + req.Seed = []byte("0123456789abcdef0123456789abcdef") + resp, err := srv.GetCompoundProof(context.Background(), req) + require.NoError(t, err) + require.True(t, resp.Ok, "error: %s", resp.Error) + require.NotEmpty(t, resp.RecipientSignature) + + offsets := make([]uint64, 0, len(req.Ranges)) + for _, rng := range req.Ranges { + offsets = append(offsets, rng.Start) + } + class := audittypes.StorageProofArtifactClass(req.ArtifactClass) + derivHash, err := deterministic.DerivationInputHash(req.Seed, req.TargetSupernodeAccount, req.TicketId, class, req.ArtifactOrdinal, offsets, uint64(deterministic.LEP6CompoundRangeLenBytes)) + require.NoError(t, err) + expectedTranscript, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ + EpochID: req.EpochId, + ChallengerSupernodeAccount: req.ChallengerAccount, + TargetSupernodeAccount: req.TargetSupernodeAccount, + TicketID: req.TicketId, + Bucket: audittypes.StorageProofBucketType(req.BucketType), + ArtifactClass: class, + ArtifactOrdinal: req.ArtifactOrdinal, + ArtifactKey: req.ArtifactKey, + DerivationInputHash: derivHash, + CompoundProofHashHex: resp.ProofHashHex, + ObserverIDs: req.ObserverAccounts, + }) + require.NoError(t, err) + + emptyDerivTranscript, err := deterministic.TranscriptHash(deterministic.TranscriptInputs{ + EpochID: req.EpochId, + ChallengerSupernodeAccount: req.ChallengerAccount, + TargetSupernodeAccount: req.TargetSupernodeAccount, + TicketID: req.TicketId, + Bucket: audittypes.StorageProofBucketType(req.BucketType), + ArtifactClass: class, + ArtifactOrdinal: req.ArtifactOrdinal, + ArtifactKey: req.ArtifactKey, + DerivationInputHash: "", + CompoundProofHashHex: resp.ProofHashHex, + ObserverIDs: req.ObserverAccounts, + }) + require.NoError(t, err) + + sig, err := hex.DecodeString(resp.RecipientSignature) + require.NoError(t, err) + rec, err := kr.Key(keyName) + require.NoError(t, err) + pub, err := rec.GetPubKey() + require.NoError(t, err) + require.True(t, pub.VerifySignature([]byte(expectedTranscript), sig), "recipient signature must verify against transcript containing derivation hash") + require.False(t, pub.VerifySignature([]byte(emptyDerivTranscript), sig), "recipient signature must not verify against empty-derivation transcript") +} + +func TestGetCompoundProof_AcceptsChainParamRangeCount(t *testing.T) { + t.Parallel() + + reader := &deterministicReader{} + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(reader) + + rng := fourValidRanges()[:3] + resp, err := srv.GetCompoundProof(context.Background(), compoundRequestWith(rng, 1<<20)) + require.NoError(t, err) + require.True(t, resp.Ok, "error: %s", resp.Error) + require.Len(t, resp.RangeBytes, 3) + require.Equal(t, 3, reader.calls) +} + +func TestGetCompoundProof_AcceptsChainParamRangeSize(t *testing.T) { + t.Parallel() + + reader := &deterministicReader{} + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(reader) + + ranges := []*supernode.ByteRange{ + {Start: 0, End: 200}, + {Start: 1024, End: 1224}, + {Start: 4096, End: 4296}, + {Start: 8192, End: 8392}, + } + resp, err := srv.GetCompoundProof(context.Background(), compoundRequestWith(ranges, 1<<20)) + require.NoError(t, err) + require.True(t, resp.Ok, "error: %s", resp.Error) + require.Len(t, resp.RangeBytes, len(ranges)) + for i, b := range resp.RangeBytes { + require.Lenf(t, b, 200, "range[%d]", i) + } + require.Equal(t, len(ranges), reader.calls) +} + +func TestGetCompoundProof_RejectsInconsistentRangeSize(t *testing.T) { + t.Parallel() + + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(&deterministicReader{}) + + bad := []*supernode.ByteRange{ + {Start: 0, End: 200}, + {Start: 1024, End: 1225}, + } + resp, err := srv.GetCompoundProof(context.Background(), compoundRequestWith(bad, 1<<20)) + require.NoError(t, err) + require.False(t, resp.Ok) + require.Contains(t, resp.Error, "invalid size") + require.Empty(t, resp.RangeBytes) +} + +func TestGetCompoundProof_RejectsEmptyRanges(t *testing.T) { + t.Parallel() + + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(&deterministicReader{}) + + resp, err := srv.GetCompoundProof(context.Background(), compoundRequestWith(nil, 1<<20)) + require.NoError(t, err) + require.False(t, resp.Ok) + require.Contains(t, resp.Error, "at least one range") + require.Empty(t, resp.RangeBytes) +} + +func TestGetCompoundProof_RangeOutOfBounds(t *testing.T) { + t.Parallel() + + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(&deterministicReader{}) + + rl := uint64(deterministic.LEP6CompoundRangeLenBytes) + rs := fourValidRanges() + // last range straddles end of artifact + artifactSize := rs[3].End - 1 + resp, err := srv.GetCompoundProof(context.Background(), compoundRequestWith(rs, artifactSize)) + require.NoError(t, err) + require.False(t, resp.Ok) + require.Contains(t, resp.Error, "out of bounds") + require.Empty(t, resp.RangeBytes) + _ = rl +} + +func TestGetCompoundProof_ReaderError(t *testing.T) { + t.Parallel() + + reader := &deterministicReader{err: io.ErrUnexpectedEOF} + srv := NewServer("recipient-1", &testP2PClient{}, nil).WithArtifactReader(reader) + + resp, err := srv.GetCompoundProof(context.Background(), compoundRequestWith(fourValidRanges(), 1<<20)) + require.NoError(t, err) + require.False(t, resp.Ok) + require.True(t, strings.Contains(resp.Error, io.ErrUnexpectedEOF.Error()), "error %q must wrap %v", resp.Error, io.ErrUnexpectedEOF) + require.Empty(t, resp.RangeBytes) +} diff --git a/tests/scripts/setup-supernodes.sh b/tests/scripts/setup-supernodes.sh index da643f5a..39cd9ee0 100755 --- a/tests/scripts/setup-supernodes.sh +++ b/tests/scripts/setup-supernodes.sh @@ -50,7 +50,7 @@ setup_primary() { CGO_ENABLED=1 \ GOOS=linux \ GOARCH=amd64 \ - go build \ + "${GO:-go}" build \ -trimpath \ -ldflags="-s -w" \ -o "$DATA_DIR/supernode" "$SUPERNODE_SRC" || error "Failed to build supernode binary" diff --git a/tests/system/config.lep6-1.yml b/tests/system/config.lep6-1.yml new file mode 100644 index 00000000..d5c36a7c --- /dev/null +++ b/tests/system/config.lep6-1.yml @@ -0,0 +1,64 @@ +# Note: During tests, local loopback/localhost is allowed by the P2P layer +# when INTEGRATION_TEST=true (set by tests). No change needed here. +# Supernode Configuration +supernode: + key_name: "testkey1" + identity: "lumera1em87kgrvgttrkvuamtetyaagjrhnu3vjy44at4" + host: "0.0.0.0" + port: 4444 + gateway_port: 8002 + +# Keyring Configuration +keyring: + backend: "test" # Options: test, file, os + dir: "keys" # Relative to base_dir + password: "keyring-password" + +# P2P Network Configuration +p2p: + port: 4445 + data_dir: "data/p2p" # Relative to base_dir + +# Lumera Chain Configuration +lumera: + grpc_addr: "localhost:9090" + chain_id: "testing" + +# RaptorQ Configuration +raptorq: + files_dir: "raptorq_files" # Relative to base_dir + +storage_challenge: + # Runtime e2e submits deterministic chain reports manually. Keep the automatic + # dispatcher off so pre-upload P2P allowlist refreshes do not perturb the + # Cascade upload baseline before the heal-op is scheduled. + enabled: false + poll_interval_ms: 5000 + submit_evidence: false + lep6: + enabled: false + max_concurrent_targets: 4 + recipient_read_timeout: 30s + # LEP-6 review L6 (Matee, 2026-05-06): the recheck runtime is gated + # by both `storage_challenge.enabled` and `storage_challenge.lep6.enabled` + # (see supernode/cmd/start.go). Both parents are explicitly false in + # this fixture (e2e tests submit reports manually), so recheck must + # also be false — otherwise the new validator rejects the config. + recheck: + enabled: false + lookback_epochs: 7 + max_per_tick: 5 + tick_interval_ms: 60000 + max_failure_attempts_per_ticket: 3 + failure_backoff_ttl_ms: 900000 +self_healing: + enabled: true + poll_interval_ms: 5000 + max_concurrent_reconstructs: 2 + max_concurrent_verifications: 4 + max_concurrent_publishes: 2 + staging_dir: supernode-lep6-data1/heal-staging + verifier_fetch_timeout_ms: 60000 + verifier_fetch_attempts: 3 + verifier_backoff_base_ms: 2000 + audit_query_timeout_ms: 2000 diff --git a/tests/system/config.lep6-2.yml b/tests/system/config.lep6-2.yml new file mode 100644 index 00000000..b5cbba69 --- /dev/null +++ b/tests/system/config.lep6-2.yml @@ -0,0 +1,65 @@ +# Note: During tests, local loopback/localhost is allowed by the P2P layer +# when INTEGRATION_TEST=true (set by tests). No change needed here. +#hope bulk clever tip road female fly quiz once dose journey sting hedgehog pull area envelope supreme maze project spike brave shed fish live +# Supernode Configuration +supernode: + key_name: "testkey2" + identity: "lumera1cf0ms9ttgdvz6zwlqfty4tjcawhuaq69p40w0c" + host: "0.0.0.0" + port: 4446 + gateway_port: 8003 + +# Keyring Configuration +keyring: + backend: "test" + dir: "keys" + password: "keyring-password" + +# P2P Network Configuration +p2p: + port: 4447 + data_dir: "data/p2p" + +# Lumera Chain Configuration +lumera: + grpc_addr: "localhost:9090" + chain_id: "testing" + +# RaptorQ Configuration +raptorq: + files_dir: "raptorq_files" + +storage_challenge: + # Runtime e2e submits deterministic chain reports manually. Keep the automatic + # dispatcher off so pre-upload P2P allowlist refreshes do not perturb the + # Cascade upload baseline before the heal-op is scheduled. + enabled: false + poll_interval_ms: 5000 + submit_evidence: false + lep6: + enabled: false + max_concurrent_targets: 4 + recipient_read_timeout: 30s + # LEP-6 review L6 (Matee, 2026-05-06): the recheck runtime is gated + # by both `storage_challenge.enabled` and `storage_challenge.lep6.enabled` + # (see supernode/cmd/start.go). Both parents are explicitly false in + # this fixture (e2e tests submit reports manually), so recheck must + # also be false — otherwise the new validator rejects the config. + recheck: + enabled: false + lookback_epochs: 7 + max_per_tick: 5 + tick_interval_ms: 60000 + max_failure_attempts_per_ticket: 3 + failure_backoff_ttl_ms: 900000 +self_healing: + enabled: true + poll_interval_ms: 5000 + max_concurrent_reconstructs: 2 + max_concurrent_verifications: 4 + max_concurrent_publishes: 2 + staging_dir: supernode-lep6-data2/heal-staging + verifier_fetch_timeout_ms: 60000 + verifier_fetch_attempts: 3 + verifier_backoff_base_ms: 2000 + audit_query_timeout_ms: 2000 diff --git a/tests/system/config.lep6-3.yml b/tests/system/config.lep6-3.yml new file mode 100644 index 00000000..b6c58ea9 --- /dev/null +++ b/tests/system/config.lep6-3.yml @@ -0,0 +1,65 @@ +# Note: During tests, local loopback/localhost is allowed by the P2P layer +# when INTEGRATION_TEST=true (set by tests). No change needed here. +#young envelope urban crucial denial zone toward mansion protect bonus exotic puppy resource pistol expand tell cupboard radio hurry world radio trust explain million +# Supernode Configuration +supernode: + key_name: "testkey3" + identity: "lumera1cjyc4ruq739e2lakuhargejjkr0q5vg6x3d7kp" + host: "0.0.0.0" + port: 4448 + gateway_port: 8004 + +# Keyring Configuration +keyring: + backend: "test" + dir: "keys" + password: "keyring-password" + +# P2P Network Configuration +p2p: + port: 4449 + data_dir: "data/p2p" + +# Lumera Chain Configuration +lumera: + grpc_addr: "localhost:9090" + chain_id: "testing" + +# RaptorQ Configuration +raptorq: + files_dir: "raptorq_files" + +storage_challenge: + # Runtime e2e submits deterministic chain reports manually. Keep the automatic + # dispatcher off so pre-upload P2P allowlist refreshes do not perturb the + # Cascade upload baseline before the heal-op is scheduled. + enabled: false + poll_interval_ms: 5000 + submit_evidence: false + lep6: + enabled: false + max_concurrent_targets: 4 + recipient_read_timeout: 30s + # LEP-6 review L6 (Matee, 2026-05-06): the recheck runtime is gated + # by both `storage_challenge.enabled` and `storage_challenge.lep6.enabled` + # (see supernode/cmd/start.go). Both parents are explicitly false in + # this fixture (e2e tests submit reports manually), so recheck must + # also be false — otherwise the new validator rejects the config. + recheck: + enabled: false + lookback_epochs: 7 + max_per_tick: 5 + tick_interval_ms: 60000 + max_failure_attempts_per_ticket: 3 + failure_backoff_ttl_ms: 900000 +self_healing: + enabled: true + poll_interval_ms: 5000 + max_concurrent_reconstructs: 2 + max_concurrent_verifications: 4 + max_concurrent_publishes: 2 + staging_dir: supernode-lep6-data3/heal-staging + verifier_fetch_timeout_ms: 60000 + verifier_fetch_attempts: 3 + verifier_backoff_base_ms: 2000 + audit_query_timeout_ms: 2000 diff --git a/tests/system/e2e_lep6_helpers_test.go b/tests/system/e2e_lep6_helpers_test.go new file mode 100644 index 00000000..bd8ccbc4 --- /dev/null +++ b/tests/system/e2e_lep6_helpers_test.go @@ -0,0 +1,964 @@ +//go:build system_test + +package system + +// This file contains helper functions used by the Supernode LEP-6 system tests. +// +// Why helpers exist here: +// - The audit module behavior depends heavily on block height (epoch boundaries). +// - The systemtest harness runs a real multi-node testnet; we need stable ways to: +// - pick a safe epoch to test against (avoid racing enforcement), +// - derive deterministic peer targets (same logic as the keeper), +// - submit reports via CLI, +// - query results reliably (gRPC where CLI JSON marshalling is known to break). + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "testing" + "time" + + client "github.com/cometbft/cometbft/rpc/client/http" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "lukechampine.com/blake3" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +// setAuditParamsForFastEpochs overrides audit module params in genesis so tests complete quickly. +func setAuditParamsForFastEpochs(t *testing.T, epochLengthBlocks uint64, peerQuorumReports, minTargets, maxTargets uint32, requiredOpenPorts []uint32) GenesisMutator { + return func(genesis []byte) []byte { + t.Helper() + + state := genesis + var err error + + state, err = sjson.SetRawBytes(state, "app_state.audit.params.epoch_length_blocks", []byte(fmt.Sprintf("%q", strconv.FormatUint(epochLengthBlocks, 10)))) + require.NoError(t, err) + // In system tests, start epoch 0 at height 1 (the first block height on a fresh chain). + state, err = sjson.SetRawBytes(state, "app_state.audit.params.epoch_zero_height", []byte(fmt.Sprintf("%q", "1"))) + require.NoError(t, err) + + state, err = sjson.SetRawBytes(state, "app_state.audit.params.peer_quorum_reports", []byte(strconv.FormatUint(uint64(peerQuorumReports), 10))) + require.NoError(t, err) + state, err = sjson.SetRawBytes(state, "app_state.audit.params.min_probe_targets_per_epoch", []byte(strconv.FormatUint(uint64(minTargets), 10))) + require.NoError(t, err) + state, err = sjson.SetRawBytes(state, "app_state.audit.params.max_probe_targets_per_epoch", []byte(strconv.FormatUint(uint64(maxTargets), 10))) + require.NoError(t, err) + + portsJSON, err := json.Marshal(requiredOpenPorts) + require.NoError(t, err) + state, err = sjson.SetRawBytes(state, "app_state.audit.params.required_open_ports", portsJSON) + require.NoError(t, err) + + return state + } +} + +// setSupernodeParamsForAuditTests keeps supernode registration permissive for test environments. +// +// These tests register supernodes and then submit audit reports "on their behalf" using node keys. +// We keep minimum stake and min version permissive so registration is not the bottleneck. +func setSupernodeParamsForAuditTests(t *testing.T) GenesisMutator { + return func(genesis []byte) []byte { + t.Helper() + + state, err := sjson.SetRawBytes(genesis, "app_state.supernode.params.min_supernode_version", []byte(`"0.0.0"`)) + require.NoError(t, err) + + coinJSON := `{"denom":"ulume","amount":"1"}` + state, err = sjson.SetRawBytes(state, "app_state.supernode.params.minimum_stake_for_sn", []byte(coinJSON)) + require.NoError(t, err) + + return state + } +} + +// ── genesis mutators ───────────────────────────────────────────────────────── + +// setStorageTruthTestParams returns a genesis mutator that overrides storage-truth params +// to enable enforcement at low thresholds so single-recheck submissions are observable. +// +// - mode: proto enum name (e.g. "STORAGE_TRUTH_ENFORCEMENT_MODE_SOFT") +// - postponeThreshold: suspicion score at which the node is postponed (SOFT/FULL only) +// - watchThreshold: suspicion score at which Watch band begins +// - healThreshold: ticket deterioration score at which heal ops are scheduled +// - decayPerEpoch: score decay factor per epoch; 0 maps to 1000/no decay for tests +// - maxHealOps: maximum self-heal ops scheduled per epoch +func setStorageTruthTestParams( + t *testing.T, + mode string, + postponeThreshold, watchThreshold, healThreshold, decayPerEpoch int64, + maxHealOps uint32, +) GenesisMutator { + return func(genesis []byte) []byte { + t.Helper() + state := genesis + var err error + + // Enum: proto3 JSON string. + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_enforcement_mode", + []byte(fmt.Sprintf("%q", mode))) + require.NoError(t, err) + + // int64 thresholds: proto3 JSON represents int64 as strings. + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_node_suspicion_threshold_postpone", + []byte(fmt.Sprintf("%q", strconv.FormatInt(postponeThreshold, 10)))) + require.NoError(t, err) + + // Set probation midway between watch and postpone. + probation := (watchThreshold + postponeThreshold) / 2 + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_node_suspicion_threshold_probation", + []byte(fmt.Sprintf("%q", strconv.FormatInt(probation, 10)))) + require.NoError(t, err) + + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_node_suspicion_threshold_watch", + []byte(fmt.Sprintf("%q", strconv.FormatInt(watchThreshold, 10)))) + require.NoError(t, err) + + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_ticket_deterioration_heal_threshold", + []byte(fmt.Sprintf("%q", strconv.FormatInt(healThreshold, 10)))) + require.NoError(t, err) + + effectiveDecay := decayPerEpoch + if effectiveDecay == 0 { + effectiveDecay = 1000 + } + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_node_suspicion_decay_per_epoch", + []byte(fmt.Sprintf("%q", strconv.FormatInt(effectiveDecay, 10)))) + require.NoError(t, err) + + // uint32: proto3 JSON number. + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_max_self_heal_ops_per_epoch", + []byte(strconv.FormatUint(uint64(maxHealOps), 10))) + require.NoError(t, err) + + // Extend the local-system-test heal deadline so real reconstruction, + // verifier polling, and tx commit latency fit inside the compressed epoch + // cadence. This preserves production defaults outside the isolated e2e + // genesis. + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_heal_deadline_epochs", + []byte("10")) + require.NoError(t, err) + + // divisor=1 ensures every active node gets an assignment so tests can always + // find a prober for any target (needed to seed transcript records for recheck). + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_challenge_target_divisor", + []byte("1")) + require.NoError(t, err) + + // strong_postpone must be >= postpone to satisfy params.Validate() in InitGenesis. + strongPostpone := postponeThreshold + 200 + state, err = sjson.SetRawBytes(state, + "app_state.audit.params.storage_truth_node_suspicion_threshold_strong_postpone", + []byte(fmt.Sprintf("%q", strconv.FormatInt(strongPostpone, 10)))) + require.NoError(t, err) + + state = seedStorageTruthSyntheticTicketCounts(t, state) + + return state + } +} + +func awaitAtLeastHeight(t *testing.T, height int64, timeout ...time.Duration) { + t.Helper() + if sut.currentHeight >= height { + return + } + sut.AwaitBlockHeight(t, height, timeout...) +} + +// pickEpochForStartAtOrAfter returns the first epoch whose start height is >= minStartHeight. +// This is a "ceiling" epoch picker. +func pickEpochForStartAtOrAfter(originHeight int64, epochBlocks uint64, minStartHeight int64) (epochID uint64, startHeight int64) { + if epochBlocks == 0 { + return 0, originHeight + } + if minStartHeight < originHeight { + minStartHeight = originHeight + } + + blocks := int64(epochBlocks) + delta := minStartHeight - originHeight + epochID = uint64((delta + blocks - 1) / blocks) // ceil(delta/blocks) + startHeight = originHeight + int64(epochID)*blocks + return epochID, startHeight +} + +// nextEpochAfterHeight returns the next epoch after the provided height. +// +// We use this in tests to: +// - register supernodes first, +// - then wait for the *next* epoch boundary to ensure snapshot inclusion and acceptance. +func nextEpochAfterHeight(originHeight int64, epochBlocks uint64, height int64) (epochID uint64, startHeight int64) { + if epochBlocks == 0 { + return 0, originHeight + } + if height < originHeight { + return 0, originHeight + } + blocks := int64(epochBlocks) + currentID := uint64((height - originHeight) / blocks) + epochID = currentID + 1 + startHeight = originHeight + int64(epochID)*blocks + return epochID, startHeight +} + +type testNodeIdentity struct { + nodeName string + accAddr string + valAddr string +} + +// getNodeIdentity reads the node's account and validator operator address from the systemtest keyring. +func getNodeIdentity(t *testing.T, cli *LumeradCli, nodeName string) testNodeIdentity { + t.Helper() + accAddr := cli.GetKeyAddr(nodeName) + valAddr := strings.TrimSpace(cli.Keys("keys", "show", nodeName, "--bech", "val", "-a")) + require.NotEmpty(t, accAddr) + require.NotEmpty(t, valAddr) + return testNodeIdentity{nodeName: nodeName, accAddr: accAddr, valAddr: valAddr} +} + +// registerSupernode registers a supernode using the node's own key as both: +// - the tx signer (via --from), +// - the supernode_account (so that later MsgSubmitEpochReport signatures match). +func registerSupernode(t *testing.T, cli *LumeradCli, id testNodeIdentity, ip string) { + t.Helper() + resp := cli.CustomCommand( + "tx", "supernode", "register-supernode", + id.valAddr, + ip, + id.accAddr, + "--from", id.nodeName, + ) + RequireTxSuccess(t, resp) + sut.AwaitNextBlock(t) +} + +// headerHashAtHeight fetches the block header hash at an exact height. +// The audit module uses ctx.HeaderHash() as the snapshot seed; the assignment logic needs this seed. +func headerHashAtHeight(t *testing.T, rpcAddr string, height int64) []byte { + t.Helper() + httpClient, err := client.New(rpcAddr, "/websocket") + require.NoError(t, err) + require.NoError(t, httpClient.Start()) + t.Cleanup(func() { _ = httpClient.Stop() }) + + res, err := httpClient.Block(context.Background(), &height) + require.NoError(t, err) + hash := res.Block.Header.Hash() + require.True(t, len(hash) >= 8, "expected header hash >= 8 bytes") + return []byte(hash) +} + +func epochSeedAtHeight(t *testing.T, rpcAddr string, height int64, epochID uint64) []byte { + t.Helper() + + raw := headerHashAtHeight(t, rpcAddr, height) + epochBz := make([]byte, 8) + binary.BigEndian.PutUint64(epochBz, epochID) + + var msg bytes.Buffer + msg.WriteString("lumera:epoch-seed") + msg.Write(raw) + msg.Write(epochBz) + + sum := blake3.Sum256(msg.Bytes()) + out := make([]byte, len(sum)) + copy(out, sum[:]) + return out +} + +// computeKEpoch replicates x/audit/v1/keeper.computeKWindow to keep tests deterministic and black-box. +// It computes how many peer targets each sender must probe this epoch. +func computeKEpoch(peerQuorumReports, minTargets, maxTargets uint32, sendersCount, receiversCount int) uint32 { + if sendersCount <= 0 || receiversCount <= 1 { + return 0 + } + + a := uint64(sendersCount) + n := uint64(receiversCount) + q := uint64(peerQuorumReports) + kNeeded := (q*n + a - 1) / a + + kMin := uint64(minTargets) + kMax := uint64(maxTargets) + if kNeeded < kMin { + kNeeded = kMin + } + if kNeeded > kMax { + kNeeded = kMax + } + if kNeeded > n-1 { + kNeeded = n - 1 + } + + return uint32(kNeeded) +} + +// assignedTargets replicates x/audit/v1/keeper.assignedTargets. +// +// Notes: +// - The assignment is order-sensitive; the module enforces that peer observations match targets by index. +// - We use this to build exactly-valid test reports. +func assignedTargets(seed []byte, senders, receivers []string, kWindow uint32, senderSupernodeAccount string) ([]string, bool) { + k := int(kWindow) + if k == 0 || len(receivers) == 0 { + return []string{}, true + } + + senderIndex := -1 + for i, s := range senders { + if s == senderSupernodeAccount { + senderIndex = i + break + } + } + if senderIndex < 0 { + return nil, false + } + if len(seed) < 8 { + return nil, false + } + + n := len(receivers) + offsetU64 := binary.BigEndian.Uint64(seed[:8]) + offset := int(offsetU64 % uint64(n)) + + seen := make(map[int]struct{}, k) + out := make([]string, 0, k) + + for j := 0; j < k; j++ { + slot := senderIndex*k + j + candidate := (offset + slot) % n + + tries := 0 + for tries < n { + if receivers[candidate] != senderSupernodeAccount { + if _, ok := seen[candidate]; !ok { + break + } + } + candidate = (candidate + 1) % n + tries++ + } + if tries >= n { + break + } + + seen[candidate] = struct{}{} + out = append(out, receivers[candidate]) + } + + return out, true +} + +// openPortStates builds PORT_STATE_OPEN entries sized to the keeper-assigned +// required_open_ports list. The audit module rejects reports whose port-state +// count does not match the assigned requirement. +func openPortStates(requiredOpenPorts []uint32) []string { + portStates := make([]string, len(requiredOpenPorts)) + for i := range portStates { + portStates[i] = "PORT_STATE_OPEN" + } + return portStates +} + +// auditHostReportJSON builds the JSON payload for the positional host-report argument. +// HostReport contains float fields (cpu/mem/disk), so we keep values simple. +func auditHostReportJSON(inboundPortStates []string) string { + bz, _ := json.Marshal(map[string]any{ + "cpu_usage_percent": 1.0, + "mem_usage_percent": 1.0, + "disk_usage_percent": 1.0, + "inbound_port_states": inboundPortStates, + "failed_actions_count": 0, + }) + return string(bz) +} + +// storageChallengeObservationJSON builds the JSON payload for --storage-challenge-observations flag. +func storageChallengeObservationJSON(targetSupernodeAccount string, portStates []string) string { + bz, _ := json.Marshal(map[string]any{ + "target_supernode_account": targetSupernodeAccount, + "port_states": portStates, + }) + return string(bz) +} + +// submitEpochReport submits a report using the AutoCLI command: +// +// tx audit submit-epoch-report [epoch-id] [host-report-json] --storage-challenge-observations ... +// +// We keep it as a CLI call to validate the end-to-end integration path (signer handling, encoding). +func submitEpochReport(t *testing.T, cli *LumeradCli, fromNode string, epochID uint64, hostReportJSON string, storageChallengeObservationJSONs []string) string { + t.Helper() + + args := []string{"tx", "audit", "submit-epoch-report", strconv.FormatUint(epochID, 10), hostReportJSON, "--from", fromNode} + for _, obs := range storageChallengeObservationJSONs { + args = append(args, "--storage-challenge-observations", obs) + } + + return cli.CustomCommand(args...) +} + +// querySupernodeLatestState reads the latest supernode state string (e.g. "SUPERNODE_STATE_POSTPONED") via CLI JSON. +func querySupernodeLatestState(t *testing.T, cli *LumeradCli, validatorAddress string) string { + t.Helper() + resp := cli.CustomQuery("q", "supernode", "get-supernode", validatorAddress) + states := gjson.Get(resp, "supernode.states") + require.True(t, states.Exists(), "missing states: %s", resp) + arr := states.Array() + require.NotEmpty(t, arr, "missing states: %s", resp) + return arr[len(arr)-1].Get("state").String() +} + +// gjsonUint64 is a small helper because some CLI outputs represent uint64 as strings. +func gjsonUint64(v gjson.Result) uint64 { + if !v.Exists() { + return 0 + } + if v.Type == gjson.Number { + return uint64(v.Uint()) + } + if v.Type == gjson.String { + out, err := strconv.ParseUint(v.String(), 10, 64) + if err != nil { + return 0 + } + return out + } + return 0 +} + +func sortedStrings(in ...string) []string { + out := append([]string(nil), in...) + sort.Strings(out) + return out +} + +// newAuditQueryClient creates a gRPC query client against node0's gRPC endpoint. +// +// - `EpochReport` contains float fields; CLI JSON marshalling for those fields is currently broken +// in this environment and fails with "unknown type float64". +func newAuditQueryClient(t *testing.T) (audittypes.QueryClient, func()) { + t.Helper() + conn, err := grpc.Dial("localhost:9090", grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + closeFn := func() { _ = conn.Close() } + t.Cleanup(closeFn) + return audittypes.NewQueryClient(conn), closeFn +} + +// auditQueryReport queries a stored report via gRPC. +func auditQueryReport(t *testing.T, epochID uint64, reporterSupernodeAccount string) audittypes.EpochReport { + t.Helper() + qc, _ := newAuditQueryClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + resp, err := qc.EpochReport(ctx, &audittypes.QueryEpochReportRequest{ + EpochId: epochID, + SupernodeAccount: reporterSupernodeAccount, + }) + require.NoError(t, err) + return resp.Report +} + +func auditQueryReporterReliabilityState(t *testing.T, reporterSupernodeAccount string) audittypes.ReporterReliabilityState { + t.Helper() + qc, _ := newAuditQueryClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + resp, err := qc.ReporterReliabilityState(ctx, &audittypes.QueryReporterReliabilityStateRequest{ + ReporterSupernodeAccount: reporterSupernodeAccount, + }) + require.NoError(t, err) + return resp.State +} + +func auditQueryAssignedTargets(t *testing.T, epochID uint64, filterByEpochID bool, proberSupernodeAccount string) audittypes.QueryAssignedTargetsResponse { + t.Helper() + qc, _ := newAuditQueryClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + resp, err := qc.AssignedTargets(ctx, &audittypes.QueryAssignedTargetsRequest{ + EpochId: epochID, + FilterByEpochId: filterByEpochID, + SupernodeAccount: proberSupernodeAccount, + }) + require.NoError(t, err) + return *resp +} + +func awaitCurrentEpochAnchorWithActiveSupernodes(t *testing.T, minEpochID uint64, expectedAccounts ...string) audittypes.EpochAnchor { + t.Helper() + qc, _ := newAuditQueryClient(t) + deadline := time.Now().Add(2 * time.Minute) + var last audittypes.EpochAnchor + var lastErr error + + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + resp, err := qc.CurrentEpochAnchor(ctx, &audittypes.QueryCurrentEpochAnchorRequest{}) + cancel() + if err == nil { + last = resp.Anchor + if last.EpochId >= minEpochID && containsAllStrings(last.ActiveSupernodeAccounts, expectedAccounts...) && containsAllStrings(last.TargetSupernodeAccounts, expectedAccounts...) { + return last + } + } else { + lastErr = err + } + sut.AwaitNextBlock(t) + } + + require.FailNowf(t, + "epoch anchor did not include expected supernodes", + "min_epoch_id=%d expected=%v last_epoch_id=%d last_active=%v last_targets=%v last_err=%v", + minEpochID, + expectedAccounts, + last.EpochId, + last.ActiveSupernodeAccounts, + last.TargetSupernodeAccounts, + lastErr, + ) + return audittypes.EpochAnchor{} +} + +func containsAllStrings(values []string, needles ...string) bool { + for _, needle := range needles { + if !containsString(values, needle) { + return false + } + } + return true +} + +// setStorageTruthEnforcementModeUnspecified sets enforcement_mode=UNSPECIFIED in genesis. +// Use this for tests that rely on the k-based peer-assignment formula rather than the +// storage-truth one-third coverage formula that activates under any non-UNSPECIFIED mode. +func setStorageTruthEnforcementModeUnspecified(t *testing.T) GenesisMutator { + return func(genesis []byte) []byte { + t.Helper() + state, err := sjson.SetRawBytes(genesis, + "app_state.audit.params.storage_truth_enforcement_mode", + []byte(`"STORAGE_TRUTH_ENFORCEMENT_MODE_UNSPECIFIED"`)) + require.NoError(t, err) + return state + } +} + +func seedStorageTruthSyntheticTicketCounts(t *testing.T, genesis []byte) []byte { + t.Helper() + + ticketIDs := []string{ + "sys-test-ticket-recheck-1", + "sys-test-ticket-soft-postpone", + "sys-test-ticket-shadow-nopostpone", + "sys-test-ticket-heal-lifecycle-1", + "edge-ticket-full-mode-recent", + "edge-ticket-full-mode-old", + "edge-ticket-unspecified", + "edge-ticket-failed-heal", + "edge-ticket-replay", + } + for i := 0; i < 3; i++ { + ticketIDs = append(ticketIDs, fmt.Sprintf("edge-ticket-decay-%d", i)) + } + for i := 0; i < 4; i++ { + ticketIDs = append(ticketIDs, fmt.Sprintf("multi-ticket-%d", i)) + } + + states := make([]map[string]any, 0, len(ticketIDs)) + for _, ticketID := range ticketIDs { + states = append(states, map[string]any{ + "ticket_id": ticketID, + "index_artifact_count": 8, + "symbol_artifact_count": 8, + }) + } + bz, err := json.Marshal(states) + require.NoError(t, err) + + state, err := sjson.SetRawBytes(genesis, "app_state.audit.ticket_artifact_count_states", bz) + require.NoError(t, err) + return state +} + +// buildStorageProofResultJSON builds a single StorageProofResult JSON object for the +// --storage-proof-results CLI flag. +// +// Uses INVALID_TRANSCRIPT result class: score-neutral (nodeSuspicion=0, ticketDeterioration=0) +// but recheck-eligible, so it seeds the on-chain transcript KV store without corrupting +// any node-suspicion or ticket-deterioration score assertions in the test. +func buildStorageProofResultJSONWithClass(challengerAcct, targetAcct, ticketID, transcriptHash, bucketType, resultClass string) string { + return buildStorageProofResultJSONWithClassAndCount(challengerAcct, targetAcct, ticketID, transcriptHash, bucketType, resultClass, 8) +} + +func buildStorageProofResultJSONWithClassAndCount(challengerAcct, targetAcct, ticketID, transcriptHash, bucketType, resultClass string, artifactCount uint32) string { + bz, _ := json.Marshal(map[string]any{ + "target_supernode_account": targetAcct, + "challenger_supernode_account": challengerAcct, + "ticket_id": ticketID, + "transcript_hash": transcriptHash, + "bucket_type": bucketType, + "result_class": resultClass, + "artifact_class": "STORAGE_PROOF_ARTIFACT_CLASS_INDEX", + "artifact_key": "seed-artifact-key", + "artifact_ordinal": 0, + "artifact_count": artifactCount, + "derivation_input_hash": "seed-derivation-hash", + "challenger_signature": "seed-challenger-signature", + }) + return string(bz) +} + +func buildStorageProofResultJSON(challengerAcct, targetAcct, ticketID, transcriptHash, bucketType string) string { + return buildStorageProofResultJSONWithClass( + challengerAcct, + targetAcct, + ticketID, + transcriptHash, + bucketType, + "STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT", + ) +} + +// submitEpochReportWithProofResults submits an epoch report that includes storage proof results +// via the AutoCLI --storage-proof-results flag. Uses an empty host report (no port measurements). +func submitEpochReportWithProofResults(t *testing.T, cli *LumeradCli, fromNode string, epochID uint64, proofResultJSONs []string) string { + t.Helper() + args := []string{ + "tx", "audit", "submit-epoch-report", + strconv.FormatUint(epochID, 10), + auditHostReportJSON([]string{}), + "--from", fromNode, + } + for _, pr := range proofResultJSONs { + args = append(args, "--storage-proof-results", pr) + } + return cli.CustomCommand(args...) +} + +type transcriptSeed struct { + ticketID string + transcriptHash string +} + +func containsString(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func findAssignedProberForTarget( + t *testing.T, + epochID uint64, + candidates []testNodeIdentity, + targetAcct string, +) (audittypes.QueryAssignedTargetsResponse, testNodeIdentity) { + t.Helper() + + var fallbackResp audittypes.QueryAssignedTargetsResponse + var fallbackProber testNodeIdentity + for _, candidate := range candidates { + resp := auditQueryAssignedTargets(t, epochID, true, candidate.accAddr) + if !containsString(resp.TargetSupernodeAccounts, targetAcct) { + continue + } + if candidate.accAddr != targetAcct { + return resp, candidate + } + fallbackResp = resp + fallbackProber = candidate + } + if fallbackProber.accAddr != "" { + return fallbackResp, fallbackProber + } + + require.FailNowf(t, "no assigned prober", "no candidate assigned to target %q in epoch %d", targetAcct, epochID) + return audittypes.QueryAssignedTargetsResponse{}, testNodeIdentity{} +} + +func findAssignedProberAndTarget( + t *testing.T, + epochID uint64, + candidates []testNodeIdentity, +) (audittypes.QueryAssignedTargetsResponse, testNodeIdentity, testNodeIdentity) { + t.Helper() + + byAccount := make(map[string]testNodeIdentity, len(candidates)) + for _, candidate := range candidates { + byAccount[candidate.accAddr] = candidate + } + + for _, candidate := range candidates { + resp := auditQueryAssignedTargets(t, epochID, true, candidate.accAddr) + for _, targetAcct := range resp.TargetSupernodeAccounts { + target, ok := byAccount[targetAcct] + if ok && target.accAddr != candidate.accAddr { + return resp, candidate, target + } + } + } + + require.FailNowf(t, "no assigned prober/target pair", "no candidate had an assigned registered target in epoch %d", epochID) + return audittypes.QueryAssignedTargetsResponse{}, testNodeIdentity{}, testNodeIdentity{} +} + +// seedProofTranscripts seeds on-chain transcript records so that subsequent +// SubmitStorageRecheckEvidence calls can reference a valid challenged_result_transcript_hash. +// +// It queries assignments to find which node in candidates is assigned targetAcct, +// submits an epoch report with INVALID_TRANSCRIPT results from that prober, then +// returns the rechecker node (any candidate ≠ prober). +// +// For fullMode=true (FULL enforcement), exactly one seed is expected and both RECENT and OLD +// results are included to satisfy compound-coverage validation. For fullMode=false, one +// RECENT result is generated per seed. +// ── gRPC query helpers ──────────────────────────────────────────────────────── + +func auditQueryNodeSuspicionStateST(t *testing.T, supernodeAccount string) (audittypes.NodeSuspicionState, bool) { + t.Helper() + conn, err := grpc.Dial("localhost:9090", grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + qc := audittypes.NewQueryClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := qc.NodeSuspicionState(ctx, &audittypes.QueryNodeSuspicionStateRequest{ + SupernodeAccount: supernodeAccount, + }) + if err != nil { + return audittypes.NodeSuspicionState{}, false + } + return resp.State, true +} + +func auditQueryTicketDeteriorationStateST(t *testing.T, ticketID string) (audittypes.TicketDeteriorationState, bool) { + t.Helper() + conn, err := grpc.Dial("localhost:9090", grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + qc := audittypes.NewQueryClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := qc.TicketDeteriorationState(ctx, &audittypes.QueryTicketDeteriorationStateRequest{ + TicketId: ticketID, + }) + if err != nil { + return audittypes.TicketDeteriorationState{}, false + } + return resp.State, true +} + +func auditQueryHealOpsByTicketST(t *testing.T, ticketID string) []audittypes.HealOp { + t.Helper() + conn, err := grpc.Dial("localhost:9090", grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + qc := audittypes.NewQueryClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := qc.HealOpsByTicket(ctx, &audittypes.QueryHealOpsByTicketRequest{ + TicketId: ticketID, + }) + if err != nil { + return nil + } + return resp.HealOps +} + +// ── CLI transaction helpers ─────────────────────────────────────────────────── + +func submitStorageRecheckEvidence( + t *testing.T, + cli *LumeradCli, + fromNode string, + epochID uint64, + challengedAccount string, + ticketID string, + challengedHash string, + recheckHash string, + resultClass string, +) string { + t.Helper() + return cli.CustomCommand( + "tx", "audit", "submit-storage-recheck-evidence", + strconv.FormatUint(epochID, 10), + challengedAccount, + ticketID, + "--challenged-result-transcript-hash", challengedHash, + "--recheck-transcript-hash", recheckHash, + "--recheck-result-class", resultClass, + "--gas", "500000", // Per CP3.5 F-B — secondary indexes for recheck reporter result push gas above 200k default. + "--from", fromNode, + ) +} + +func submitClaimHealCompleteST( + t *testing.T, + cli *LumeradCli, + fromNode string, + healOpID uint64, + ticketID string, + manifestHash string, +) string { + t.Helper() + return cli.CustomCommand( + "tx", "audit", "claim-heal-complete", + strconv.FormatUint(healOpID, 10), + ticketID, + manifestHash, + "--from", fromNode, + ) +} + +func submitHealVerificationST( + t *testing.T, + cli *LumeradCli, + fromNode string, + healOpID uint64, + verified bool, + verificationHash string, +) string { + t.Helper() + return cli.CustomCommand( + "tx", "audit", "submit-heal-verification", + strconv.FormatUint(healOpID, 10), + strconv.FormatBool(verified), + verificationHash, + "--from", fromNode, + ) +} + +func seedProofTranscripts( + t *testing.T, + cli *LumeradCli, + epochID uint64, + candidates []testNodeIdentity, + targetAcct string, + seeds []transcriptSeed, + fullMode bool, +) testNodeIdentity { + t.Helper() + return seedProofTranscriptsWithClass(t, cli, epochID, candidates, targetAcct, seeds, fullMode, "STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT") +} + +func seedProofTranscriptsWithClass( + t *testing.T, + cli *LumeradCli, + epochID uint64, + candidates []testNodeIdentity, + targetAcct string, + seeds []transcriptSeed, + fullMode bool, + resultClass string, +) testNodeIdentity { + t.Helper() + + var prober, rechecker testNodeIdentity + proberIdx := -1 + var proberResp audittypes.QueryAssignedTargetsResponse + for i, c := range candidates { + resp := auditQueryAssignedTargets(t, epochID, true, c.accAddr) + for _, a := range resp.TargetSupernodeAccounts { + if a == targetAcct { + prober = c + proberIdx = i + proberResp = resp + break + } + } + if proberIdx >= 0 { + break + } + } + require.GreaterOrEqual(t, proberIdx, 0, + "no candidate assigned to %q in epoch %d — check challenge_target_divisor=1 in genesis", targetAcct, epochID) + for i, c := range candidates { + if i != proberIdx && c.accAddr != targetAcct { + rechecker = c + break + } + } + require.NotEmpty(t, rechecker.accAddr, "no rechecker available — candidates must include a node distinct from prober and target") + + // Build port states sized to required_open_ports (chain rejects mismatched lengths). + portStates := make([]string, len(proberResp.RequiredOpenPorts)) + for j := range portStates { + portStates[j] = "PORT_STATE_OPEN" + } + + // Probers must include peer observations for ALL assigned targets. + var observations []string + for _, tgt := range proberResp.TargetSupernodeAccounts { + observations = append(observations, storageChallengeObservationJSON(tgt, portStates)) + } + + var proofResults []string + for _, s := range seeds { + proofResults = append(proofResults, buildStorageProofResultJSONWithClass( + prober.accAddr, targetAcct, s.ticketID, s.transcriptHash, + "STORAGE_PROOF_BUCKET_TYPE_RECENT", + resultClass, + )) + if fullMode { + // FULL mode requires both RECENT and OLD results for every assigned target. + proofResults = append(proofResults, buildStorageProofResultJSONWithClass( + prober.accAddr, targetAcct, s.ticketID, s.transcriptHash+"-old-seed", + "STORAGE_PROOF_BUCKET_TYPE_OLD", + resultClass, + )) + } + } + + // Submit full epoch report: host report + peer observations + proof results. + args := []string{ + "tx", "audit", "submit-epoch-report", + strconv.FormatUint(epochID, 10), + auditHostReportJSON(portStates), + "--from", prober.nodeName, + "--gas", "500000", + } + for _, obs := range observations { + args = append(args, "--storage-challenge-observations", obs) + } + for _, pr := range proofResults { + args = append(args, "--storage-proof-results", pr) + } + seedResp := cli.CustomCommand(args...) + RequireTxSuccess(t, seedResp) + sut.AwaitNextBlock(t) + + return rechecker +} diff --git a/tests/system/e2e_lep6_runtime_test.go b/tests/system/e2e_lep6_runtime_test.go new file mode 100644 index 00000000..09043370 --- /dev/null +++ b/tests/system/e2e_lep6_runtime_test.go @@ -0,0 +1,515 @@ +//go:build system_test + +package system + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/cascadekit" + "github.com/LumeraProtocol/supernode/v2/pkg/keyring" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + "github.com/LumeraProtocol/supernode/v2/sdk/action" + sdkconfig "github.com/LumeraProtocol/supernode/v2/sdk/config" + "github.com/LumeraProtocol/supernode/v2/sdk/event" + "github.com/LumeraProtocol/supernode/v2/supernode/config" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// TestLEP6RuntimeE2E_CascadeChallengeHealVerifyAndStore mirrors the shape of +// TestCascadeE2E, but extends it through the LEP-6 runtime path: +// +// 1. start real lumerad + three real supernode processes; +// 2. upload a real CASCADE action and prove normal download works; +// 3. submit a real storage-challenge epoch report for that action/ticket; +// 4. wait for chain to schedule a heal-op; +// 5. let the assigned healer supernode reconstruct+stage and claim; +// 6. let assigned verifier supernodes fetch healer-served bytes and verify; +// 7. wait for chain VERIFIED and finalizer publish; +// 8. download the action again and assert bytes still match the original. +// +// The storage-challenge report is driven by the test so the failure is +// deterministic; the healer/verifier/finalizer data-plane is driven by the real +// supernode self_healing services. +func TestLEP6RuntimeE2E_CascadeChallengeHealVerifyAndStore(t *testing.T) { + os.Setenv("INTEGRATION_TEST", "true") + os.Setenv("LUMERA_SUPERNODE_DISABLE_HOST_REPORTER", "1") + defer os.Unsetenv("INTEGRATION_TEST") + defer os.Unsetenv("LUMERA_SUPERNODE_DISABLE_HOST_REPORTER") + + const ( + epochLengthBlocks = uint64(12) + originHeight = int64(1) + lumeraGRPCAddr = "localhost:9090" + lumeraChainID = "testing" + testKeyName = "testkey1" + testMnemonic = "odor kiss switch swarm spell make planet bundle skate ozone path planet exclude butter atom ahead angle royal shuffle door prevent merry alter robust" + testKey2Mnemonic = "club party current length duck agent love into slide extend spawn sentence kangaroo chunk festival order plate rare public good include situate liar miss" + testKey3Mnemonic = "young envelope urban crucial denial zone toward mansion protect bonus exotic puppy resource pistol expand tell cupboard radio hurry world radio trust explain million" + expectedAddress = "lumera1em87kgrvgttrkvuamtetyaagjrhnu3vjy44at4" + userKeyName = "user" + userMnemonic = "little tone alley oval festival gloom sting asthma crime select swap auto when trip luxury pact risk sister pencil about crisp upon opera timber" + fundAmount = "1000000ulume" + actionType = "CASCADE" + ) + + t.Log("Step 1: configure genesis and start chain") + sut.ModifyGenesisJSON(t, + SetStakingBondDenomUlume(t), + SetActionParams(t), + SetSupernodeMetricsParams(t), + setSupernodeParamsForAuditTests(t), + setAuditParamsForFastEpochs(t, epochLengthBlocks, 1, 1, 1, []uint32{4444}), + setAuditMissingReportGraceForRuntimeE2E(t), + setStorageTruthTestParams(t, "STORAGE_TRUTH_ENFORCEMENT_MODE_FULL", 1000, 500, 10, 0, 10), + ) + sut.StartChain(t) + cli := NewLumeradCLI(t, sut, true) + + t.Log("Step 2: register and fund three supernodes") + binaryPath := locateExecutable(sut.ExecBinary) + homePath := filepath.Join(WorkDir, sut.outputDir) + recoverChainKey(t, binaryPath, homePath, testKeyName, testMnemonic) + recoverChainKey(t, binaryPath, homePath, "testkey2", testKey2Mnemonic) + recoverChainKey(t, binaryPath, homePath, "testkey3", testKey3Mnemonic) + recoverChainKey(t, binaryPath, homePath, userKeyName, userMnemonic) + + n0 := getRuntimeSupernodeIdentity(t, cli, "node0", "testkey1") + n1 := getRuntimeSupernodeIdentity(t, cli, "node1", "testkey2") + n2 := getRuntimeSupernodeIdentity(t, cli, "node2", "testkey3") + registerRuntimeSupernode(t, cli, "node0", n0, "localhost:4444", "4445") + registerRuntimeSupernode(t, cli, "node1", n1, "localhost:4446", "4447") + registerRuntimeSupernode(t, cli, "node2", n2, "localhost:4448", "4449") + cli.FundAddress(n0.accAddr, "100000ulume") + cli.FundAddress(n1.accAddr, "100000ulume") + cli.FundAddress(n2.accAddr, "100000ulume") + bootstrapRuntimeSupernodeEligibility(t, cli) + + t.Log("Step 3: recover user/test keys and start real supernodes") + recoveredAddress := cli.GetKeyAddr(testKeyName) + require.Equal(t, expectedAddress, recoveredAddress) + userAddress := cli.GetKeyAddr(userKeyName) + cli.FundAddress(recoveredAddress, fundAmount) + cli.FundAddress(userAddress, fundAmount) + sut.AwaitNextBlock(t) + + cmds := StartLEP6Supernodes(t) + defer StopAllSupernodes(cmds) + time.Sleep(40 * time.Second) // Match Cascade e2e: allow supernode P2P/DHT routing to settle before upload. + + t.Log("Step 4: upload a real Cascade action through the SDK/supernodes") + ctx := context.Background() + kr, err := keyring.InitKeyring(config.KeyringConfig{Backend: "memory", Dir: ""}) + require.NoError(t, err) + _, err = keyring.RecoverAccountFromMnemonic(kr, testKeyName, testMnemonic) + require.NoError(t, err) + userRecord, err := keyring.RecoverAccountFromMnemonic(kr, userKeyName, userMnemonic) + require.NoError(t, err) + userLocalAddr, err := userRecord.GetAddress() + require.NoError(t, err) + require.Equal(t, userAddress, userLocalAddr.String()) + + lumeraCfg, err := lumera.NewConfig(lumeraGRPCAddr, lumeraChainID, userKeyName, kr) + require.NoError(t, err) + lumeraClient, err := lumera.NewClient(ctx, lumeraCfg) + require.NoError(t, err) + defer lumeraClient.Close() + + actionClient, err := action.NewClient(ctx, sdkconfig.Config{ + Account: sdkconfig.AccountConfig{KeyName: userKeyName, Keyring: kr}, + Lumera: sdkconfig.LumeraConfig{GRPCAddr: lumeraGRPCAddr, ChainID: lumeraChainID}, + }, nil) + require.NoError(t, err) + + testFileFullpath := filepath.Join("test.txt") + originalData := readFileBytes(t, testFileFullpath) + originalHash := sha256.Sum256(originalData) + + actionID := requestAndStartCascadeAction(t, ctx, cli, lumeraClient, actionClient, testFileFullpath, actionType) + require.NoError(t, waitForActionStateWithClient(ctx, lumeraClient, actionID, actiontypes.ActionStateDone)) + artifactCounts := requireFinalizedCascadeArtifactCounts(t, ctx, lumeraClient, actionID) + + t.Log("Step 5: prove pre-heal Cascade download works") + preHealDir := t.TempDir() + downloadAndAssertCascadeBytes(t, ctx, actionClient, actionID, userAddress, preHealDir, originalData, originalHash) + + t.Log("Step 6: submit deterministic storage-challenge report for the Cascade action ticket") + currentHeight := sut.AwaitNextBlock(t) + epochID, epochStart := nextEpochAfterHeight(originHeight, epochLengthBlocks, currentHeight) + epochEnd := epochStart + int64(epochLengthBlocks) + awaitAtLeastHeight(t, epochStart) + anchor := awaitCurrentEpochAnchorWithActiveSupernodes(t, epochID, n0.accAddr, n1.accAddr, n2.accAddr) + require.ElementsMatch(t, []string{n0.accAddr, n1.accAddr, n2.accAddr}, anchor.ActiveSupernodeAccounts) + + nodes := []testNodeIdentity{n0, n1, n2} + proberResp, prober, target := findAssignedProberAndTarget(t, epochID, nodes) + portStates := openPortStates(proberResp.RequiredOpenPorts) + reportArgs := []string{ + "tx", "audit", "submit-epoch-report", + strconv.FormatUint(epochID, 10), + auditHostReportJSON(portStates), + "--from", prober.nodeName, + "--gas", "500000", + } + for _, assignedTarget := range proberResp.TargetSupernodeAccounts { + reportArgs = append(reportArgs, "--storage-challenge-observations", storageChallengeObservationJSON(assignedTarget, portStates)) + } + reportArgs = append(reportArgs, + "--storage-proof-results", buildStorageProofResultJSONWithClassAndCount( + prober.accAddr, + target.accAddr, + actionID, + "runtime-e2e-recent-hash-mismatch", + "STORAGE_PROOF_BUCKET_TYPE_RECENT", + "STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH", + artifactCounts.index, + ), + "--storage-proof-results", buildStorageProofResultJSONWithClassAndCount( + prober.accAddr, + target.accAddr, + actionID, + "runtime-e2e-old-hash-mismatch", + "STORAGE_PROOF_BUCKET_TYPE_OLD", + "STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH", + artifactCounts.index, + ), + ) + reportResp := cli.CustomCommand(reportArgs...) + RequireTxSuccess(t, reportResp) + sut.AwaitNextBlock(t) + + ticketBefore, found := auditQueryTicketDeteriorationStateST(t, actionID) + require.True(t, found, "storage challenge failure for the action/ticket must create deterioration state") + require.GreaterOrEqual(t, ticketBefore.DeteriorationScore, int64(10), "ticket score must cross heal threshold before scheduling") + + t.Log("Step 7: wait for chain heal-op schedule and real supernode self-healing runtime") + awaitAtLeastHeight(t, epochEnd) + sut.AwaitNextBlock(t) + healOps := auditQueryHealOpsByTicketST(t, actionID) + require.Len(t, healOps, 1, "chain must schedule one heal op for the deteriorated Cascade action ticket") + healOp := healOps[0] + require.False(t, isFinalStatusForRuntimeE2E(healOp.Status), "newly observed heal op must not already be final: %s", healOp.Status.String()) + require.NotEmpty(t, healOp.HealerSupernodeAccount) + require.NotEmpty(t, healOp.VerifierSupernodeAccounts) + + verified := awaitAnyHealOpStatusByTicket(t, actionID, audittypes.HealOpStatus_HEAL_OP_STATUS_VERIFIED, 6*time.Minute) + require.NotEmpty(t, verified.ResultHash, "real healer must submit the reconstructed file BLAKE3 manifest hash before verifier quorum") + + healerDataDir := dataDirForSupernodeAccount(t, verified.HealerSupernodeAccount, n0, n1, n2) + stagingDir := filepath.Join(healerDataDir, "heal-staging", fmt.Sprintf("%d", verified.HealOpId)) + awaitStagingDirRemoved(t, stagingDir, 90*time.Second) + + ticketAfter, found := auditQueryTicketDeteriorationStateST(t, actionID) + require.True(t, found) + require.Less(t, ticketAfter.DeteriorationScore, ticketBefore.DeteriorationScore, "VERIFIED heal must reduce ticket deterioration") + + t.Log("Step 8: prove post-heal Cascade data remains retrievable and byte-identical") + postHealDir := t.TempDir() + downloadAndAssertCascadeBytes(t, ctx, actionClient, actionID, userAddress, postHealDir, originalData, originalHash) +} + +type finalizedCascadeArtifactCounts struct { + index uint32 + symbol uint32 +} + +func requireFinalizedCascadeArtifactCounts(t *testing.T, ctx context.Context, client lumera.Client, actionID string) finalizedCascadeArtifactCounts { + t.Helper() + resp, err := client.Action().GetAction(ctx, actionID) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Action) + meta, err := cascadekit.UnmarshalCascadeMetadata(resp.Action.Metadata) + require.NoError(t, err) + require.NotZero(t, meta.IndexArtifactCount, "finalized Cascade action metadata must include LEP-6 index artifact count") + require.NotZero(t, meta.SymbolArtifactCount, "finalized Cascade action metadata must include LEP-6 symbol artifact count") + t.Logf("Finalized Cascade artifact counts for action %s: index=%d symbol=%d", actionID, meta.IndexArtifactCount, meta.SymbolArtifactCount) + return finalizedCascadeArtifactCounts{index: meta.IndexArtifactCount, symbol: meta.SymbolArtifactCount} +} + +func recoverChainKey(t *testing.T, binaryPath, homePath, keyName, mnemonic string) { + t.Helper() + cmd := exec.Command(binaryPath, "keys", "add", keyName, "--recover", "--keyring-backend=test", "--home", homePath) + cmd.Stdin = strings.NewReader(mnemonic + "\n") + out, err := cmd.CombinedOutput() + require.NoError(t, err, "recover key %s failed: %s", keyName, string(out)) +} + +func setAuditMissingReportGraceForRuntimeE2E(t *testing.T) GenesisMutator { + return func(genesis []byte) []byte { + t.Helper() + state, err := sjson.SetRawBytes(genesis, "app_state.audit.params.consecutive_epochs_to_postpone", []byte("100")) + require.NoError(t, err) + return state + } +} + +func readFileBytes(t *testing.T, path string) []byte { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() + b, err := io.ReadAll(f) + require.NoError(t, err) + return b +} + +func getRuntimeSupernodeIdentity(t *testing.T, cli *LumeradCli, validatorKey, supernodeKey string) testNodeIdentity { + t.Helper() + accAddr := cli.GetKeyAddr(supernodeKey) + valAddr := strings.TrimSpace(cli.Keys("keys", "show", validatorKey, "--bech", "val", "-a")) + require.NotEmpty(t, accAddr) + require.NotEmpty(t, valAddr) + return testNodeIdentity{nodeName: supernodeKey, accAddr: accAddr, valAddr: valAddr} +} + +func registerRuntimeSupernode(t *testing.T, cli *LumeradCli, signerKey string, id testNodeIdentity, grpcAddress, p2pPort string) { + t.Helper() + resp := cli.CustomCommand( + "tx", "supernode", "register-supernode", + id.valAddr, + grpcAddress, + id.accAddr, + "--p2p-port", p2pPort, + "--from", signerKey, + ) + RequireTxSuccess(t, resp) + sut.AwaitNextBlock(t) +} + +func bootstrapRuntimeSupernodeEligibility(t *testing.T, cli *LumeradCli) { + t.Helper() + listResp := cli.CustomQuery("query", "supernode", "list-supernodes", "--output", "json") + t.Logf("Registered supernodes response: %s", listResp) + require.NotEqual(t, "{}", strings.TrimSpace(listResp), "registered supernodes must be visible before Cascade bootstrap") + + queryHeight := sut.AwaitNextBlock(t) + resp := cli.CustomQuery( + "query", "supernode", "get-top-supernodes-for-block", + fmt.Sprint(queryHeight), + "--output", "json", + ) + t.Logf("Bootstrap top-supernodes response at height %d: %s", queryHeight, resp) + require.NotEmpty(t, strings.TrimSpace(resp), "top-supernodes bootstrap query must return a response") +} + +func requestAndStartCascadeAction(t *testing.T, ctx context.Context, cli *LumeradCli, lc lumera.Client, ac action.Client, filePath, actionType string) string { + t.Helper() + meta, price, expiration, err := ac.BuildCascadeMetadataFromFile(ctx, filePath, false, "") + require.NoError(t, err) + metaBytes, err := json.Marshal(meta) + require.NoError(t, err) + fi, err := os.Stat(filePath) + require.NoError(t, err) + fileSizeKbs := (fi.Size() + 1023) / 1024 + resp, err := lc.ActionMsg().RequestAction(ctx, actionType, string(metaBytes), price, expiration, strconv.FormatInt(fileSizeKbs, 10)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Zero(t, resp.TxResponse.Code, "RequestAction tx failed: %s", resp.TxResponse.RawLog) + sut.AwaitNextBlock(t) + + txResp := awaitTxQuery(t, cli, resp.TxResponse.TxHash, 45*time.Second) + require.Equal(t, int64(0), gjson.Get(txResp, "code").Int(), "RequestAction tx query failed: %s", txResp) + actionID := extractActionIDFromTx(t, txResp) + + txHashCh := make(chan string, 1) + completionCh := make(chan struct{}, 1) + errCh := make(chan string, 1) + err = ac.SubscribeToAllEvents(context.Background(), func(ctx context.Context, e event.Event) { + switch e.Type { + case event.SDKTaskTxHashReceived: + if txHash, ok := e.Data[event.KeyTxHash].(string); ok && txHash != "" { + select { + case txHashCh <- txHash: + default: + } + } + case event.SDKTaskCompleted: + select { + case completionCh <- struct{}{}: + default: + } + case event.SDKTaskFailed: + msg, _ := e.Data[event.KeyError].(string) + if msg == "" { + msg = "cascade task failed without an SDK error message" + } + select { + case errCh <- msg: + default: + } + } + }) + require.NoError(t, err) + + time.Sleep(5 * time.Second) + sig, err := ac.GenerateStartCascadeSignatureFromFile(ctx, filePath) + require.NoError(t, err) + _, err = ac.StartCascade(ctx, filePath, actionID, sig) + require.NoError(t, err) + + var finalizeTxHash string + completed := false + timeout := time.After(3 * time.Minute) + for finalizeTxHash == "" || !completed { + select { + case h := <-txHashCh: + if finalizeTxHash == "" { + finalizeTxHash = h + } + case <-completionCh: + completed = true + case msg := <-errCh: + t.Fatalf("cascade task reported failure: %s", msg) + case <-timeout: + t.Fatalf("timeout waiting for cascade SDK events; finalizeTxHash=%q completed=%v", finalizeTxHash, completed) + } + } + finalizeResp := awaitTxQuery(t, cli, finalizeTxHash, 45*time.Second) + require.Equal(t, int64(0), gjson.Get(finalizeResp, "code").Int(), "Cascade finalize tx failed: %s", finalizeResp) + return actionID +} + +func awaitTxQuery(t *testing.T, cli *LumeradCli, txHash string, timeout time.Duration) string { + t.Helper() + deadline := time.Now().Add(timeout) + var last string + binaryPath := locateExecutable(sut.ExecBinary) + for time.Now().Before(deadline) { + cmd := exec.Command(binaryPath, "query", "tx", txHash, "--output", "json", "--node", "tcp://localhost:26657") + outBytes, _ := cmd.CombinedOutput() + out := string(outBytes) + last = out + lower := strings.ToLower(out) + if strings.Contains(lower, "tx not found") || strings.Contains(lower, "rpc error") || strings.Contains(lower, "usage:") { + time.Sleep(time.Second) + continue + } + return out + } + t.Fatalf("tx %s was not queryable before timeout; last=%s", txHash, last) + return "" +} + +func extractActionIDFromTx(t *testing.T, txResp string) string { + t.Helper() + for _, event := range gjson.Get(txResp, "events").Array() { + if event.Get("type").String() != "action_registered" { + continue + } + for _, attr := range event.Get("attributes").Array() { + if attr.Get("key").String() == "action_id" { + return attr.Get("value").String() + } + } + } + t.Fatalf("action_id not found in tx response: %s", txResp) + return "" +} + +func downloadAndAssertCascadeBytes(t *testing.T, ctx context.Context, ac action.Client, actionID, userAddress, outputBaseDir string, originalData []byte, originalHash [32]byte) { + t.Helper() + sig, err := ac.GenerateDownloadSignature(ctx, actionID, userAddress) + require.NoError(t, err) + _, err = ac.DownloadCascade(ctx, actionID, outputBaseDir, sig) + require.NoError(t, err) + outDir := filepath.Join(outputBaseDir, actionID) + require.Eventually(t, func() bool { + entries, err := os.ReadDir(outDir) + return err == nil && len(entries) > 0 + }, 45*time.Second, time.Second, "download output directory should contain reconstructed file") + entries, err := os.ReadDir(outDir) + require.NoError(t, err) + var downloadedPath string + for _, entry := range entries { + if !entry.IsDir() { + downloadedPath = filepath.Join(outDir, entry.Name()) + break + } + } + require.NotEmpty(t, downloadedPath, "download output must contain a file") + downloaded := readFileBytes(t, downloadedPath) + require.Equal(t, len(originalData), len(downloaded), "downloaded size must match original") + require.Equal(t, originalHash, sha256.Sum256(downloaded), "downloaded hash must match original") +} + +func isFinalStatusForRuntimeE2E(status audittypes.HealOpStatus) bool { + switch status { + case audittypes.HealOpStatus_HEAL_OP_STATUS_VERIFIED, + audittypes.HealOpStatus_HEAL_OP_STATUS_FAILED, + audittypes.HealOpStatus_HEAL_OP_STATUS_EXPIRED: + return true + default: + return false + } +} + +func awaitAnyHealOpStatusByTicket(t *testing.T, ticketID string, status audittypes.HealOpStatus, timeout time.Duration) audittypes.HealOp { + t.Helper() + deadline := time.Now().Add(timeout) + var last []audittypes.HealOp + for time.Now().Before(deadline) { + healOps := auditQueryHealOpsByTicketST(t, ticketID) + last = healOps + for _, op := range healOps { + if op.Status == status { + return op + } + } + time.Sleep(2 * time.Second) + } + t.Fatalf("no heal op for ticket %s reached %s before timeout; last=%+v", ticketID, status.String(), last) + return audittypes.HealOp{} +} + +func awaitHealOpStatusByTicket(t *testing.T, ticketID string, healOpID uint64, status audittypes.HealOpStatus, timeout time.Duration) audittypes.HealOp { + t.Helper() + deadline := time.Now().Add(timeout) + var last audittypes.HealOp + for time.Now().Before(deadline) { + for _, op := range auditQueryHealOpsByTicketST(t, ticketID) { + if op.HealOpId == healOpID { + last = op + if op.Status == status { + return op + } + } + } + time.Sleep(3 * time.Second) + } + t.Fatalf("heal op %d for ticket %s did not reach %s before timeout; last=%+v", healOpID, ticketID, status.String(), last) + return audittypes.HealOp{} +} + +func dataDirForSupernodeAccount(t *testing.T, account string, nodes ...testNodeIdentity) string { + t.Helper() + for i, node := range nodes { + if node.accAddr == account { + return filepath.Join(".", fmt.Sprintf("supernode-lep6-data%d", i+1)) + } + } + t.Fatalf("supernode account %q not found in test nodes", account) + return "" +} + +func awaitStagingDirRemoved(t *testing.T, stagingDir string, timeout time.Duration) { + t.Helper() + require.Eventually(t, func() bool { + _, err := os.Stat(stagingDir) + return os.IsNotExist(err) + }, timeout, 3*time.Second, "verified heal finalizer should publish then remove staging dir %s", stagingDir) +} diff --git a/tests/system/e2e_lep6_test.go b/tests/system/e2e_lep6_test.go new file mode 100644 index 00000000..1db994ad --- /dev/null +++ b/tests/system/e2e_lep6_test.go @@ -0,0 +1,60 @@ +package system + +import ( + "fmt" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestLEP6RealChainIntegration exercises the real Lumera binary/local-chain +// harness. It intentionally avoids mocks: genesis is mutated, lumerad nodes are +// started, audit queries go through the live RPC endpoint, and a LEP-6 tx command +// is submitted far enough to be rejected by the real audit keeper. +func TestLEP6RealChainIntegration(t *testing.T) { + sut.ModifyGenesisJSON(t, SetAuditParams(t)) + sut.StartChain(t) + + cli := NewLumeradCLI(t, sut, true) + + t.Run("audit query surface is available", func(t *testing.T) { + params := cli.CustomQuery("query", "audit", "params", "--output", "json") + require.True(t, gjson.Valid(params), "audit params query must return JSON: %s", params) + require.NotEmpty(t, gjson.Get(params, "params").Raw, "audit params response must contain params: %s", params) + + currentEpoch := cli.CustomQuery("query", "audit", "current-epoch", "--output", "json") + require.True(t, gjson.Valid(currentEpoch), "current epoch query must return JSON: %s", currentEpoch) + }) + + t.Run("heal tx command is wired to chain validation", func(t *testing.T) { + out := runLumeradNoCheck(t, + "tx", "audit", "claim-heal-complete", "999999", "missing-ticket", "missing-manifest-hash", + "--from", "node0", + "--yes", + "--gas", "auto", + "--gas-adjustment", "1.5", + "--fees", "10ulume", + "--broadcast-mode", "sync", + "--output", "json", + ) + require.Contains(t, out, "heal op 999999 not found", "absent heal-op claim should be rejected by the real audit keeper: %s", out) + }) +} + +func runLumeradNoCheck(t *testing.T, args ...string) string { + t.Helper() + binaryPath := locateExecutable(sut.ExecBinary) + homePath := filepath.Join(WorkDir, sut.outputDir) + base := []string{ + "--home", homePath, + "--keyring-backend", "test", + "--chain-id", "testing", + "--node", "tcp://localhost:26657", + } + cmd := exec.Command(binaryPath, append(args, base...)...) + out, _ := cmd.CombinedOutput() + return fmt.Sprintf("%s", out) +} diff --git a/tests/system/genesis_io.go b/tests/system/genesis_io.go index 8b94e742..8b654d28 100644 --- a/tests/system/genesis_io.go +++ b/tests/system/genesis_io.go @@ -65,3 +65,25 @@ func SetDefaultDenoms(t *testing.T, denom string) GenesisMutator { return state } } + +func SetAuditParams(t *testing.T) GenesisMutator { + return func(genesis []byte) []byte { + t.Helper() + updates := map[string]any{ + "app_state.audit.params.epoch_length_blocks": uint64(5), + "app_state.audit.params.sc_enabled": true, + "app_state.audit.params.sc_challengers_per_epoch": uint32(3), + "app_state.audit.params.storage_truth_enforcement_mode": "STORAGE_TRUTH_ENFORCEMENT_MODE_FULL", + "app_state.audit.params.storage_truth_max_self_heal_ops_per_epoch": uint32(3), + "app_state.audit.params.storage_truth_heal_deadline_epochs": uint32(2), + "app_state.audit.params.storage_truth_heal_verifier_count": uint32(2), + } + state := genesis + var err error + for path, value := range updates { + state, err = sjson.SetBytes(state, path, value) + require.NoError(t, err) + } + return state + } +} diff --git a/tests/system/go.mod b/tests/system/go.mod index aeb95b7a..3222e311 100644 --- a/tests/system/go.mod +++ b/tests/system/go.mod @@ -11,7 +11,7 @@ replace ( require ( cosmossdk.io/math v1.5.3 - github.com/LumeraProtocol/lumera v1.12.0-rc + github.com/LumeraProtocol/lumera v1.12.0 github.com/LumeraProtocol/supernode/v2 v2.0.0-00010101000000-000000000000 github.com/cometbft/cometbft v0.38.21 github.com/cosmos/ibc-go/v10 v10.5.0 @@ -19,6 +19,7 @@ require ( github.com/tidwall/sjson v1.2.5 golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b gopkg.in/yaml.v3 v3.0.1 + lukechampine.com/blake3 v1.4.1 ) require ( @@ -39,7 +40,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.77.0 // indirect + google.golang.org/grpc v1.77.0 ) require ( @@ -183,7 +184,6 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gotest.tools/v3 v3.5.2 // indirect - lukechampine.com/blake3 v1.4.1 // indirect nhooyr.io/websocket v1.8.17 // indirect pgregory.net/rapid v1.2.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect diff --git a/tests/system/go.sum b/tests/system/go.sum index 815a8d71..6c90ab12 100644 --- a/tests/system/go.sum +++ b/tests/system/go.sum @@ -107,8 +107,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 h1:ig/FpDD2JofP/NExKQUbn7uOSZzJAQqogfqluZK4ed4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/LumeraProtocol/lumera v1.12.0-rc h1:Mfae496LpjYhf1SvAE/bsmtjgdoOD8WAJFRCier8xsg= -github.com/LumeraProtocol/lumera v1.12.0-rc/go.mod h1:/G9LTPZB+261tHoWoj7q+1fn+O/VV0zzagwLdsThSNo= +github.com/LumeraProtocol/lumera v1.12.0 h1:BHkPF/vCKyGFKtl2MMxtRpUyzraJ96rWY9FniTbG6cU= +github.com/LumeraProtocol/lumera v1.12.0/go.mod h1:/G9LTPZB+261tHoWoj7q+1fn+O/VV0zzagwLdsThSNo= github.com/LumeraProtocol/rq-go v0.2.1 h1:8B3UzRChLsGMmvZ+UVbJsJj6JZzL9P9iYxbdUwGsQI4= github.com/LumeraProtocol/rq-go v0.2.1/go.mod h1:APnKCZRh1Es2Vtrd2w4kCLgAyaL5Bqrkz/BURoRJ+O8= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= diff --git a/tests/system/supernode-utils.go b/tests/system/supernode-utils.go index 4d74d372..ec18d7a4 100644 --- a/tests/system/supernode-utils.go +++ b/tests/system/supernode-utils.go @@ -11,17 +11,23 @@ import ( ) func StartAllSupernodes(t *testing.T) []*exec.Cmd { + return StartSupernodesFromDirs(t, []string{"supernode-data1", "supernode-data2", "supernode-data3"}, "supernode") +} + +func StartLEP6Supernodes(t *testing.T) []*exec.Cmd { + return StartSupernodesFromDirs(t, []string{"supernode-lep6-data1", "supernode-lep6-data2", "supernode-lep6-data3"}, "supernode-lep6") +} + +func StartSupernodesFromDirs(t *testing.T, relDataDirs []string, logPrefix string) []*exec.Cmd { // Determine the project root (assumes tests run from project root) wd, err := os.Getwd() if err != nil { t.Fatalf("unable to get working directory: %v", err) } - // Data directories for all three supernodes - dataDirs := []string{ - filepath.Join(wd, "supernode-data1"), - filepath.Join(wd, "supernode-data2"), - filepath.Join(wd, "supernode-data3"), + dataDirs := make([]string, 0, len(relDataDirs)) + for _, rel := range relDataDirs { + dataDirs = append(dataDirs, filepath.Join(wd, rel)) } cmds := make([]*exec.Cmd, len(dataDirs)) @@ -47,7 +53,7 @@ func StartAllSupernodes(t *testing.T) []*exec.Cmd { "--basedir", dataDir, ) - logPath := filepath.Join(wd, fmt.Sprintf("supernode%d.out", i)) + logPath := filepath.Join(wd, fmt.Sprintf("%s%d.out", logPrefix, i)) logFile, err := os.Create(logPath) if err != nil { t.Fatalf("failed to create supernode log file %s: %v", logPath, err)