diff --git a/.dockerignore b/.dockerignore index 9c48e7b076..e46b29ee0f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -31,6 +31,11 @@ token-tracker/ **/gen/**/*.go !**/gen/gen.go !**/gen/cmd/cmd.go +!pkg/chain/ethereum/frost/gen/abi/*.go +!pkg/chain/ethereum/frost/gen/validatorabi/*.go +!pkg/chain/ethereum/tbtc/gen/abi/*.go +!pkg/chain/ethereum/tbtc/gen/contract/*.go +!pkg/chain/ethereum/tbtc/gen/cmd/*.go # Legacy V1 contracts bindings. # We won't generate new bindings in the docker build process, but use the existing ones. diff --git a/.github/actions/install-yarn-deps/action.yml b/.github/actions/install-yarn-deps/action.yml index 3a3725fb04..54fb27a7fc 100644 --- a/.github/actions/install-yarn-deps/action.yml +++ b/.github/actions/install-yarn-deps/action.yml @@ -43,6 +43,10 @@ runs: export YARN_ENABLE_HARDENED_MODE=0 corepack enable yarn --version + SAFE_GIT_HOME="${RUNNER_TEMP:-/tmp}/git-clean-home" + SAFE_GIT_CONFIG="${SAFE_GIT_HOME}/.gitconfig" + mkdir -p "$SAFE_GIT_HOME" + touch "$SAFE_GIT_CONFIG" unset_args=() while IFS='=' read -r key _; do case "$key" in @@ -50,7 +54,7 @@ runs: esac done < <(env) env "${unset_args[@]}" \ + GIT_CONFIG_GLOBAL="$SAFE_GIT_CONFIG" \ GIT_CONFIG_NOSYSTEM=1 \ - GIT_CONFIG_SYSTEM=/dev/null \ GIT_CONFIG_COUNT=0 \ yarn install --immutable diff --git a/.github/actions/setup-git-for-yarn/action.yml b/.github/actions/setup-git-for-yarn/action.yml index cbb36c167b..69704589cc 100644 --- a/.github/actions/setup-git-for-yarn/action.yml +++ b/.github/actions/setup-git-for-yarn/action.yml @@ -1,8 +1,9 @@ name: Setup Git for Yarn (git dependencies) description: > Configure Git so Yarn can clone dependencies (e.g. thesis/eslint-config) on - GitHub-hosted runners. Sets GIT_CONFIG_GLOBAL and GIT_CONFIG_NOSYSTEM to avoid - broken system config ("invalid key core.autocrlf" / unable to write config). + GitHub-hosted runners. Sets GIT_CONFIG_GLOBAL to a writable temporary file and + GIT_CONFIG_NOSYSTEM to avoid broken system config ("invalid key core.autocrlf" + / unable to write config). runs: using: composite steps: @@ -14,9 +15,11 @@ runs: WRAPPER_DIR="${RUNNER_TEMP:-/tmp}/git-clean-bin" SAFE_HOME="${SAFE_TMP}/git-clean-home" SAFE_XDG_CONFIG_HOME="${SAFE_TMP}/git-clean-xdg-config" + SAFE_GLOBAL_CONFIG="${SAFE_HOME}/.gitconfig" REAL_GIT="$(command -v git)" mkdir -p "$WRAPPER_DIR" "$SAFE_HOME" "$SAFE_XDG_CONFIG_HOME" + touch "$SAFE_GLOBAL_CONFIG" printf '%s\n' \ '#!/usr/bin/env bash' \ 'set -euo pipefail' \ @@ -28,9 +31,8 @@ runs: 'done < <(env)' \ "export HOME=\"${SAFE_HOME}\"" \ "export XDG_CONFIG_HOME=\"${SAFE_XDG_CONFIG_HOME}\"" \ - 'export GIT_CONFIG_GLOBAL=/dev/null' \ + "export GIT_CONFIG_GLOBAL=\"${SAFE_GLOBAL_CONFIG}\"" \ 'export GIT_CONFIG_NOSYSTEM=1' \ - 'export GIT_CONFIG_SYSTEM=/dev/null' \ 'export GIT_CONFIG_COUNT=0' \ "if ! pwd >/dev/null 2>&1; then cd \"${SAFE_TMP}\"; fi" \ "exec \"${REAL_GIT}\" \"\$@\"" \ diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 7f29b21212..817f6466c4 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -310,6 +310,55 @@ jobs: install-go: false checks: "-SA1019" + # Compiles and unit-tests the interactive FROST + ROAST retry coordinator path, + # which lives behind the `frost_roast_retry` (and `frost_native`) build tags. The + # default untagged build the other jobs run compiles only the `!frost_roast_retry` + # no-op stubs, so without this job the entire ROAST retry state machine (liveness + + # evidence/blame) and the ~30 `frost_native` unit tests never compile or run in CI. + # + # This is the mock-FFI path: it exercises the Go coordinator flow with cgo DISABLED, + # so it needs neither libfrost_tbtc (the Rust signer lib) nor Docker. The real-crypto + # cgo variant of these tags is gated separately by frost-cgo-integration.yml. + client-frost-roast-retry: + needs: client-detect-changes + if: | + github.event_name == 'push' + || needs.client-detect-changes.outputs.path-filter == 'true' + runs-on: ubuntu-latest + env: + CGO_ENABLED: "0" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: Build the ROAST retry coordinator path (mock FFI, no cgo) + run: | + # `frost_roast_retry` alone compiles the coordinator registry / evidence / + # blame flow against mock producers; adding `frost_native` also compiles the + # native-engine <-> retry integration files (mock FFI engine, still no lib). + go build -tags "frost_roast_retry" ./... + go build -tags "frost_native frost_roast_retry" ./... + + - name: Unit-test the tagged path (mock FFI, no cgo) + run: | + # These three tag sets cover every non-cgo tagged test file in pkg/frost and + # pkg/tbtc: + # frost_native -> frost_native (and && !frost_roast_retry) + # frost_roast_retry -> frost_roast_retry (and && !frost_native) + # frost_native frost_roast_retry -> the intersection integration tests + # The `frost_native frost_tbtc_signer cgo` real-crypto tests are NOT run here; + # they are covered by frost-cgo-integration.yml. + for tags in \ + "frost_native" \ + "frost_roast_retry" \ + "frost_native frost_roast_retry"; do + echo "::group::go test -tags \"$tags\"" + go test -tags "$tags" -count=1 -timeout 25m ./pkg/frost/... ./pkg/tbtc/... + echo "::endgroup::" + done + client-integration-test: needs: [client-detect-changes, electrum-integration-detect-changes, client-build-test-publish] if: | diff --git a/.github/workflows/frost-cgo-integration.yml b/.github/workflows/frost-cgo-integration.yml new file mode 100644 index 0000000000..5faa59cdbc --- /dev/null +++ b/.github/workflows/frost-cgo-integration.yml @@ -0,0 +1,138 @@ +name: FROST cgo integration + +# Pre-merge validation gate for the interactive FROST cgo path (RFC-21 Phase 7.3). +# +# The rest of CI builds NONE of the `frost_native frost_tbtc_signer cgo` tags, so the +# real-crypto interactive tests (which link libfrost_tbtc, built from the Rust signer +# crate) are otherwise validated only locally and can silently rot. This job builds the +# lib from a PINNED signer source (ci/frost-signer-pin.env) and runs the cgo-tagged tests +# against it with skips FORBIDDEN (KEEP_CORE_FROST_REQUIRE_CGO=true), so a stale/missing/ +# unloadable lib fails the job instead of quietly dropping coverage. +# +# The tag set also includes `frost_roast_retry`, so the ROAST retry coordinator flow is +# compiled and tested against real crypto here (the mock-FFI variant runs in client.yml), +# and the job smoke-builds the full activation artifact via `make build-frost`. The +# non-cgo tagged build/test coverage lives in the client.yml `client-frost-roast-retry` +# job; together they cover the tag matrix that the default untagged CI never touches. +# +# The Go interactive code and the signer crate currently live on separate branches, so +# this checks the pinned mirror commit out into a side path and builds from there. After +# the branches merge, replace the cross-branch checkout with an in-tree cargo build and +# keep the gate. + +on: + pull_request: + paths: + - "pkg/frost/**" + - "pkg/tbtc/**" + - "pkg/net/**" + - "go.mod" + - "go.sum" + - "Makefile" + - "ci/frost-signer-pin.env" + - ".github/workflows/frost-cgo-integration.yml" + workflow_dispatch: {} + +jobs: + frost-cgo-integration: + runs-on: ubuntu-latest + steps: + - name: Check out the Go branch + uses: actions/checkout@v4 + + - name: Read the pinned signer source ref + id: pin + run: | + set -euo pipefail + # shellcheck disable=SC1091 + source ci/frost-signer-pin.env + if [ -z "${FROST_SIGNER_MIRROR_REF:-}" ]; then + echo "FROST_SIGNER_MIRROR_REF is not set in ci/frost-signer-pin.env" + exit 1 + fi + echo "ref=${FROST_SIGNER_MIRROR_REF}" >> "$GITHUB_OUTPUT" + echo "Pinned tbtc-signer source: ${FROST_SIGNER_MIRROR_REF}" + + - name: Check out the pinned signer source + uses: actions/checkout@v4 + with: + ref: ${{ steps.pin.outputs.ref }} + path: _signer-mirror + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: Build libfrost_tbtc from the pinned source + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/_frost-target + run: | + set -euo pipefail + test -f _signer-mirror/pkg/tbtc/signer/Cargo.toml || { + echo "signer crate not found at the pinned ref (_signer-mirror/pkg/tbtc/signer)"; exit 1; } + cargo build --manifest-path _signer-mirror/pkg/tbtc/signer/Cargo.toml + lib="${CARGO_TARGET_DIR}/debug/libfrost_tbtc.so" + test -f "$lib" || { echo "libfrost_tbtc.so not found at $lib"; exit 1; } + echo "FROST_LIB_DIR=${CARGO_TARGET_DIR}/debug" >> "$GITHUB_ENV" + + - name: Verify the engine ABI symbols are exported + run: | + set -euo pipefail + lib="${FROST_LIB_DIR}/libfrost_tbtc.so" + # A representative slice of the symbols the cgo bridge resolves via + # dlsym(RTLD_DEFAULT). A miss here means the pinned lib is stale vs. the bridge; + # fail with a clear message (the require-cgo run below would also catch it). + missing=0 + for sym in \ + frost_tbtc_run_dkg \ + frost_tbtc_derive_interactive_attempt_context \ + frost_tbtc_interactive_session_open \ + frost_tbtc_interactive_session_abort \ + frost_tbtc_new_signing_package \ + frost_tbtc_interactive_aggregate \ + frost_tbtc_verify_signature_share \ + frost_tbtc_version \ + frost_tbtc_abi_version; do + if ! nm -D --defined-only "$lib" | grep -q " ${sym}$"; then + echo "missing exported symbol: ${sym}" + missing=1 + fi + done + test "$missing" -eq 0 || { echo "pinned libfrost_tbtc is missing required symbols"; exit 1; } + + - name: Run the cgo-tagged real-crypto tests (skips forbidden) + env: + CGO_ENABLED: "1" + KEEP_CORE_FROST_REQUIRE_CGO: "true" + run: | + set -euo pipefail + # --no-as-needed keeps the DT_NEEDED on libfrost_tbtc even though the bridge + # references its symbols only via dlsym (no direct references), so the loader + # makes them resolvable at runtime; rpath lets the test binary find the .so. + export CGO_LDFLAGS="-L${FROST_LIB_DIR} -Wl,--no-as-needed -lfrost_tbtc -Wl,-rpath,${FROST_LIB_DIR}" + # Run the WHOLE tagged pkg/frost/signing suite against the linked lib, not a + # narrow -run subset. `frost_roast_retry` is added to the tag set so the ROAST + # retry coordinator flow (liveness + evidence/blame) is compiled and tested + # against real crypto here too, not only in the mock-FFI job (client.yml). + # With KEEP_CORE_FROST_REQUIRE_CGO=true a lib-unavailable would-be skip becomes + # a hard failure, so a stale/missing symbol fails the gate instead of silently + # dropping coverage. This gate is the only CI that builds the cgo tag set. + go test -tags "frost_native frost_tbtc_signer frost_roast_retry" -count=1 \ + -timeout 20m \ + ./pkg/frost/signing/ + + - name: Smoke-build the FROST activation artifact (make build-frost) + env: + CGO_ENABLED: "1" + run: | + set -euo pipefail + # Proves the release/activation binary - the keep-client built with + # `frost_native frost_tbtc_signer frost_roast_retry` and linked against + # libfrost_tbtc - actually compiles and links. The default `make build` the + # release/Docker path runs ships the `!frost_roast_retry` no-op stubs; this is + # the target that produces the ROAST-retry-enabled artifact once the readiness + # manifest flips. Reuses the same lib built above (frost_lib_dir=FROST_LIB_DIR). + make build-frost frost_lib_dir="${FROST_LIB_DIR}" + test -x ./keep-client || { echo "keep-client activation binary was not produced"; exit 1; } + ./keep-client --version diff --git a/Dockerfile b/Dockerfile index 97181a29ef..91646a4bf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,7 @@ COPY ./pkg/chain/ethereum/common/gen $APP_DIR/pkg/chain/ethereum/common/gen COPY ./pkg/chain/ethereum/ecdsa/gen $APP_DIR/pkg/chain/ethereum/ecdsa/gen COPY ./pkg/chain/ethereum/tbtc/gen $APP_DIR/pkg/chain/ethereum/tbtc/gen COPY ./pkg/chain/ethereum/threshold/gen $APP_DIR/pkg/chain/ethereum/threshold/gen +COPY ./pkg/frost/roast/gen $APP_DIR/pkg/frost/roast/gen COPY ./pkg/net/gen $APP_DIR/pkg/net/gen COPY ./pkg/tbtc/gen $APP_DIR/pkg/tbtc/gen COPY ./pkg/tecdsa/dkg/gen $APP_DIR/pkg/tecdsa/dkg/gen diff --git a/Makefile b/Makefile index ab468ae08f..3945045c33 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,10 @@ else $(foreach module,$(modules),$(call get_npm_package,$(module),$(environment))) endif +# NOTE: a new *.proto package must also be added to the gen-directory COPY +# allowlist in the Dockerfile: the image strips committed **/gen/**/*.go +# (.dockerignore) and regenerates protobufs in-image, and make generate only +# sees gen directories copied before it runs. proto_files := $(shell find ./pkg -name '*.proto') proto_targets := $(proto_files:.proto=.pb.go) @@ -125,6 +129,39 @@ build: $(info Building Go code) $(call go_build_cmd,.,$(app_name)) +# FROST activation build. +# +# Produces the keep-client binary with the interactive FROST + ROAST retry +# coordinator path compiled in (build tags `frost_native frost_tbtc_signer +# frost_roast_retry`). The default `build` target above compiles the +# `!frost_roast_retry` no-op stubs, so the released image ships with the ROAST +# retry coordinator disabled; this target is the artifact that actually enables +# it once the readiness manifest flips (see +# docs/development/frost-roast-retry-rollout.adoc). +# +# Unlike `build` this is a cgo build that links libfrost_tbtc (the Rust signer +# lib built from the tbtc-signer crate). Point `frost_lib_dir` at the directory +# holding the built `libfrost_tbtc.{so,dylib}`. The CGO_LDFLAGS mirror the +# frost-cgo-integration CI workflow: `--no-as-needed` keeps the DT_NEEDED entry +# even though the cgo bridge resolves the frost_tbtc_* symbols via dlsym (no +# direct references), and the rpath lets the binary find the lib at runtime. +# These are GNU-ld flags; the target is meant for the Linux CI/release toolchain. +frost_build_tags := frost_native frost_tbtc_signer frost_roast_retry + +ifndef frost_lib_dir +override frost_lib_dir = $(CURDIR)/_frost-target/debug +endif + +build-frost: + $(info Building Go code with the FROST activation tags [$(frost_build_tags)]) + CGO_ENABLED=1 \ + CGO_LDFLAGS="-L$(frost_lib_dir) -Wl,--no-as-needed -lfrost_tbtc -Wl,-rpath,$(frost_lib_dir)" \ + go build \ + -tags "$(frost_build_tags)" \ + -ldflags "-X github.com/keep-network/keep-core/build.Version=$(version) -X github.com/keep-network/keep-core/build.Revision=$(revision) -checklinkname=0" \ + -o $(app_name) \ + . + platforms := linux/amd64 \ darwin/amd64 @@ -146,4 +183,4 @@ cmd-help: build @echo '$$ $(app_name) start --help' > docs/resources/client-start-help ./$(app_name) start --help >> docs/resources/client-start-help -.PHONY: all development sepolia download_artifacts generate gen_proto build cmd-help release build_multi +.PHONY: all development sepolia download_artifacts generate gen_proto build build-frost cmd-help release build_multi diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env new file mode 100644 index 0000000000..021df67e3b --- /dev/null +++ b/ci/frost-signer-pin.env @@ -0,0 +1,18 @@ +# Pinned tbtc-signer (libfrost_tbtc) source for the frost-cgo-integration CI gate. +# +# The Go cgo bridge (pkg/frost/signing, build tags `frost_native frost_tbtc_signer`) +# resolves frost_tbtc_* symbols at runtime from libfrost_tbtc, which is built from the +# Rust signer crate. The Go interactive code and that crate currently live on SEPARATE +# branches of this repo (the Go "scaffold" branch vs. the "mirror" branch that holds +# pkg/tbtc/signer), so the gate checks out THIS exact mirror commit, builds the lib, and +# runs the cgo-tagged Go tests against it with skips FORBIDDEN. +# +# Why an explicit pin (not the mirror branch tip): the bridge skips on a stale lib that +# is missing a newer symbol. If CI tracked the moving tip, an engine/bridge drift could +# silently turn the real-crypto tests into skips (lost coverage) or break the gate for +# code nobody reviewed together. Bump this SHA deliberately, in the same PR that requires +# a newer engine ABI, so the change is reviewed as one unit. +# +# After the scaffold and mirror branches merge into one, replace the cross-branch +# checkout with an in-tree cargo build and retire this pin (keep the gate). +FROST_SIGNER_MIRROR_REF=4ac4a42ee81911e82f222d71b66c13d1f58a0773 diff --git a/cmd/flags.go b/cmd/flags.go index 7a67ad5df8..77f0fae369 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -310,6 +310,36 @@ func initTbtcFlags(cmd *cobra.Command, cfg *config.Config) { tbtc.DefaultKeyGenerationConcurrency, "tECDSA key generation concurrency.", ) + + cmd.Flags().StringVar( + &cfg.Tbtc.FrostSigningBackend, + "tbtc.frostSigningBackend", + "", + "FROST signing backend name (legacy, native, ffi). "+ + "`native` allows transitional legacy fallback; `ffi` requires native execution. "+ + "Empty value selects legacy.", + ) + + cmd.Flags().BoolVar( + &cfg.Tbtc.DisableLegacyECDSA, + "tbtc.disableLegacyECDSA", + false, + "Disable legacy ECDSA wallet DKG and pre-params generation for FROST-only deployments.", + ) + + cmd.Flags().BoolVar( + &cfg.Tbtc.DisableLegacySortitionPoolMonitoring, + "tbtc.disableLegacySortitionPoolMonitoring", + false, + "Disable legacy ECDSA sortition pool monitoring for FROST-only deployments.", + ) + + cmd.Flags().BoolVar( + &cfg.Tbtc.DisableFrostSortitionPoolMonitoring, + "tbtc.disableFrostSortitionPoolMonitoring", + false, + "Disable FROST sortition pool monitoring for operators that manage FROST pool membership out of band.", + ) } // Initialize flags for Maintainer configuration. @@ -395,5 +425,7 @@ func initDeveloperFlags(command *cobra.Command) { initContractAddressFlag(chainEthereum.RandomBeaconContractName) initContractAddressFlag(chainEthereum.TokenStakingContractName) initContractAddressFlag(chainEthereum.WalletRegistryContractName) + initContractAddressFlag(chainEthereum.FrostWalletRegistryContractName) + initContractAddressFlag(chainEthereum.FrostDkgValidatorContractName) initContractAddressFlag(chainEthereum.WalletProposalValidatorContractName) } diff --git a/cmd/flags_test.go b/cmd/flags_test.go index bb313cf50c..36e29a1a22 100644 --- a/cmd/flags_test.go +++ b/cmd/flags_test.go @@ -225,6 +225,40 @@ var cmdFlagsTests = map[string]struct { expectedValueFromFlag: 101, defaultValue: runtime.GOMAXPROCS(0), }, + "tbtc.frostSigningBackend": { + readValueFunc: func(c *config.Config) interface{} { return c.Tbtc.FrostSigningBackend }, + flagName: "--tbtc.frostSigningBackend", + flagValue: "native", + expectedValueFromFlag: "native", + defaultValue: "", + }, + "tbtc.disableLegacyECDSA": { + readValueFunc: func(c *config.Config) interface{} { + return c.Tbtc.DisableLegacyECDSA + }, + flagName: "--tbtc.disableLegacyECDSA", + flagValue: "", // don't provide any value + expectedValueFromFlag: true, + defaultValue: false, + }, + "tbtc.disableLegacySortitionPoolMonitoring": { + readValueFunc: func(c *config.Config) interface{} { + return c.Tbtc.DisableLegacySortitionPoolMonitoring + }, + flagName: "--tbtc.disableLegacySortitionPoolMonitoring", + flagValue: "", // don't provide any value + expectedValueFromFlag: true, + defaultValue: false, + }, + "tbtc.disableFrostSortitionPoolMonitoring": { + readValueFunc: func(c *config.Config) interface{} { + return c.Tbtc.DisableFrostSortitionPoolMonitoring + }, + flagName: "--tbtc.disableFrostSortitionPoolMonitoring", + flagValue: "", // don't provide any value + expectedValueFromFlag: true, + defaultValue: false, + }, "maintainer.bitcoinDifficulty": { readValueFunc: func(c *config.Config) interface{} { return c.Maintainer.BitcoinDifficulty.Enabled }, flagName: "--bitcoinDifficulty", diff --git a/cmd/helpers.go b/cmd/helpers.go index ee3fa35416..44a15d8ae1 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -98,6 +98,8 @@ func buildContractAddresses(lineLength int, prefix, suffix string, ethereumConfi chainEthereum.RandomBeaconContractName, chainEthereum.BridgeContractName, chainEthereum.WalletRegistryContractName, + chainEthereum.FrostWalletRegistryContractName, + chainEthereum.FrostDkgValidatorContractName, chainEthereum.TokenStakingContractName, chainEthereum.WalletProposalValidatorContractName, } diff --git a/config/config_test.go b/config/config_test.go index f8de558c4e..be57133265 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -16,6 +16,7 @@ import ( "github.com/keep-network/keep-core/pkg/chain/ethereum" ethereumBeacon "github.com/keep-network/keep-core/pkg/chain/ethereum/beacon/gen" ethereumEcdsa "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen" + ethereumFrost "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen" ethereumTbtc "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen" ethereumThreshold "github.com/keep-network/keep-core/pkg/chain/ethereum/threshold/gen" ) @@ -68,6 +69,8 @@ func TestReadConfigFromFile(t *testing.T) { expectedValue: map[string]string{ "randombeacon": "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb", "walletregistry": "0x143ba24e66fce8bca22f7d739f9a932c519b1c76", + "frostwalletregistry": "0x0000000000000000000000000000000000000000", + "frostdkgvalidator": "0x0000000000000000000000000000000000000000", "tokenstaking": "0xa363a197f1bbb8877f50350234e3f15fb4175457", "bridge": "0x138D2a0c87BA9f6BE1DCc13D6224A6aCE9B6b6F0", "maintainerproxy": "0xC6D21c2871586A2B098c0ad043fF0D47a3c7e7ae", @@ -337,6 +340,8 @@ func TestReadConfig_ReadContracts(t *testing.T) { ethereumBeacon.RandomBeaconAddress = "0xd1640b381327c2d5425d6d3d605539a3db72f857" ethereumEcdsa.WalletRegistryAddress = "0xdb3dd6d4f43d39c996d0afeb6fbabc284f9ffb1a" + ethereumFrost.FrostWalletRegistryAddress = "0x0000000000000000000000000000000000000000" + ethereumFrost.FrostDkgValidatorAddress = "0x0000000000000000000000000000000000000000" ethereumThreshold.TokenStakingAddress = "0xaa7b41039ea8f9ec2d89bbe96e19f97b6c267a27" ethereumTbtc.BridgeAddress = "0x9490165195503fcf6a0fd20ac113223fefb66ed5" ethereumTbtc.WalletProposalValidatorAddress = "0xE7d33d8AA55B73a93059a24b900366894684a497" @@ -346,6 +351,8 @@ func TestReadConfig_ReadContracts(t *testing.T) { expectedRandomBeaconAddress string expectedWalletRegistryAddress string + expectedFrostWalletRegistryAddress string + expectedFrostDkgValidatorAddress string expectedTokenStakingAddress string expectedBridgeAddress string expectedWalletProposalValidatorAddress string @@ -354,6 +361,8 @@ func TestReadConfig_ReadContracts(t *testing.T) { configFilePath: "../test/config_no_contracts.toml", expectedRandomBeaconAddress: "0xd1640b381327c2d5425d6d3d605539a3db72f857", expectedWalletRegistryAddress: "0xdb3dd6d4f43d39c996d0afeb6fbabc284f9ffb1a", + expectedFrostWalletRegistryAddress: "0x0000000000000000000000000000000000000000", + expectedFrostDkgValidatorAddress: "0x0000000000000000000000000000000000000000", expectedTokenStakingAddress: "0xaa7b41039ea8f9ec2d89bbe96e19f97b6c267a27", expectedBridgeAddress: "0x9490165195503fcf6a0fd20ac113223fefb66ed5", expectedWalletProposalValidatorAddress: "0xE7d33d8AA55B73a93059a24b900366894684a497", @@ -362,6 +371,8 @@ func TestReadConfig_ReadContracts(t *testing.T) { configFilePath: "../test/config.toml", expectedRandomBeaconAddress: "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb", expectedWalletRegistryAddress: "0x143ba24e66fce8bca22f7d739f9a932c519b1c76", + expectedFrostWalletRegistryAddress: "0x0000000000000000000000000000000000000000", + expectedFrostDkgValidatorAddress: "0x0000000000000000000000000000000000000000", expectedTokenStakingAddress: "0xa363a197f1bbb8877f50350234e3f15fb4175457", expectedBridgeAddress: "0x138D2a0c87BA9f6BE1DCc13D6224A6aCE9B6b6F0", expectedWalletProposalValidatorAddress: "0xfdc315b0e608b7cDE9166D9D69a1506779e3E0CA", @@ -370,6 +381,8 @@ func TestReadConfig_ReadContracts(t *testing.T) { configFilePath: "../test/config_mixed_contracts.toml", expectedRandomBeaconAddress: "0xd1640b381327c2d5425d6d3d605539a3db72f857", expectedWalletRegistryAddress: "0x143ba24e66fce8bca22f7d739f9a932c519b1c76", + expectedFrostWalletRegistryAddress: "0x0000000000000000000000000000000000000000", + expectedFrostDkgValidatorAddress: "0x0000000000000000000000000000000000000000", expectedTokenStakingAddress: "0xaa7b41039ea8f9ec2d89bbe96e19f97b6c267a27", expectedBridgeAddress: "0x9490165195503fcf6a0fd20ac113223fefb66ed5", expectedWalletProposalValidatorAddress: "0xE7d33d8AA55B73a93059a24b900366894684a497", @@ -404,6 +417,14 @@ func TestReadConfig_ReadContracts(t *testing.T) { validate(ethereum.RandomBeaconContractName, test.expectedRandomBeaconAddress) validate(ethereum.WalletRegistryContractName, test.expectedWalletRegistryAddress) + validate( + ethereum.FrostWalletRegistryContractName, + test.expectedFrostWalletRegistryAddress, + ) + validate( + ethereum.FrostDkgValidatorContractName, + test.expectedFrostDkgValidatorAddress, + ) validate(ethereum.TokenStakingContractName, test.expectedTokenStakingAddress) validate(ethereum.BridgeContractName, test.expectedBridgeAddress) validate(ethereum.WalletProposalValidatorContractName, test.expectedWalletProposalValidatorAddress) diff --git a/config/contracts.go b/config/contracts.go index aea80e1f61..1b8ab6cc3b 100644 --- a/config/contracts.go +++ b/config/contracts.go @@ -12,6 +12,7 @@ import ( ethereumBeacon "github.com/keep-network/keep-core/pkg/chain/ethereum/beacon/gen" ethereumEcdsa "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen" + ethereumFrost "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen" ethereumTbtc "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen" ethereumThreshold "github.com/keep-network/keep-core/pkg/chain/ethereum/threshold/gen" ) @@ -39,6 +40,8 @@ func initializeContractAddressesAliases() { aliasEthereumContract(chainEthereum.RandomBeaconContractName) aliasEthereumContract(chainEthereum.TokenStakingContractName) aliasEthereumContract(chainEthereum.WalletRegistryContractName) + aliasEthereumContract(chainEthereum.FrostWalletRegistryContractName) + aliasEthereumContract(chainEthereum.FrostDkgValidatorContractName) aliasEthereumContract(chainEthereum.BridgeContractName) aliasEthereumContract(chainEthereum.MaintainerProxyContractName) aliasEthereumContract(chainEthereum.LightRelayContractName) @@ -73,6 +76,14 @@ func (c *Config) resolveContractsAddresses() { chainEthereum.WalletRegistryContractName, ethereumEcdsa.WalletRegistryAddress, ) + resolveContractAddress( + chainEthereum.FrostWalletRegistryContractName, + ethereumFrost.FrostWalletRegistryAddress, + ) + resolveContractAddress( + chainEthereum.FrostDkgValidatorContractName, + ethereumFrost.FrostDkgValidatorAddress, + ) resolveContractAddress( chainEthereum.BridgeContractName, ethereumTbtc.BridgeAddress, diff --git a/docs/development/frost-readiness-manifest.adoc b/docs/development/frost-readiness-manifest.adoc new file mode 100644 index 0000000000..1382c48daa --- /dev/null +++ b/docs/development/frost-readiness-manifest.adoc @@ -0,0 +1,55 @@ += FROST Readiness Manifest + +*Status:* Living document - gate states change only with attached +evidence. +*Owner:* Threshold Labs +*Update discipline:* a gate flips from `missing-no-go` to `present` +only when the supporting evidence is linked in the same change. No +early flips, no flips on intention (RFC-21, "Phase 7: Readiness +manifest evidence"). Reviewers should reject a flip whose evidence +link does not substantiate the stated condition. + +This manifest was originally planned for the tBTC monorepo's +`docs/operations/` directory. The monorepo signer is retired and +`keep-core` is canonical, so the manifest lives here. Production +binaries adopt the `frost_roast_retry` build tag once the gates +below read `present` +(`docs/development/frost-roast-retry-rollout.adoc`). + +== Gates + +[cols="2,1,4,3"] +|=== +| Gate | State | Flip conditions | Evidence + +| ROAST retry +| `missing-no-go` +| (1) RFC-21 Phase 6 call-site migration shipped - *met*, see the + Phase 6 status section of RFC-21 (one live receive loop migrated; + the generic two-round exchange was removed as unreachable in + commit `1c692bf03`). + + (2) The `frost_roast_retry` integration suite has been run against + a real testnet deployment - *pending*. + + (3) Verified migration off FrostUniFFIV1 signer material across + production signers - *pending* (the DKG-pubkey extraction helper + rejects V1 material, so ROAST retry silently falls back to legacy + on V1-bearing nodes; see the rollout doc). +| Phase 6 status: RFC-21. Testnet run: none yet. V1 migration: none + yet. + +| Transition evidence +| `missing-no-go` +| Same testnet-run condition as ROAST retry, exercised end to end: + coordinator-produced `TransitionMessage` bundles (signed-body + envelopes per the evidence wire format) observed driving + next-attempt participant selection on testnet, with the + byte-preservation contract intact across at least one node + restart. +| None yet. +|=== + +== Change log + +* 2026-06-12: manifest created in keep-core (moved from the retired + monorepo plan); both gates recorded `missing-no-go` with Phase 6 + completion noted as met. diff --git a/docs/development/frost-roast-retry-rollout.adoc b/docs/development/frost-roast-retry-rollout.adoc new file mode 100644 index 0000000000..378baa7beb --- /dev/null +++ b/docs/development/frost-roast-retry-rollout.adoc @@ -0,0 +1,216 @@ += FROST/ROAST Retry Rollout Guide + +*Author:* Threshold Labs +*Status:* Draft +*Date:* 2026-05-23 + +== Summary + +This document describes the operational lifecycle of the +ROAST-driven retry path introduced by RFC-21 +(`docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc`) +and implemented across Phases 1-7 of that RFC. It is intended for +node operators and release engineers planning a rollout of the new +retry semantics. + +The feature ships as a build-tagged code path. A production +binary built without the tag contains *no ROAST retry code*; +every signing flow uses the pre-RFC-21 legacy retry shuffle. A +binary built with the tag still executes the legacy path unless +the operator explicitly opts in via an environment variable, and +even then the new path silently falls back to legacy whenever its +preconditions are not met. + +== Activation prerequisites + +All three must be true at the same time for the ROAST retry path +to influence participant selection on a given session attempt: + +. *Build tag set.* The keep-core binary is built with + `-tags frost_roast_retry`. Without the tag, the dispatcher + package does not include the ROAST selector at all. +. *Operator opt-in env var.* The runtime environment defines + `KEEP_CORE_FROST_ROAST_RETRY_ENABLED=true` (case-insensitive, + whitespace-trimmed). The variable is read per call (not + cached), so an operator can flip the switch during a debugging + session without restarting the node. +. *Coordinator registered.* A caller has invoked + `signing.RegisterRoastRetryCoordinator(deps)` at process + startup with the node's operator-key signer, the network's + signature verifier, and the node's member index. + +When any of these is missing, the receive loops, executor +adapter, and signing-loop selector all behave as in the legacy +pre-RFC-21 path. The behavioural rollback is therefore *configuration- +only*: toggle the env var off and the next signing attempt uses +the legacy retry shuffle. + +== Behavioural matrix + +[options="header"] +|=== +| Build tag | Env var | Registry | Bundle present | Behaviour +| not set | _any_ | _any_ | _any_ | Legacy retry shuffle +| set | unset | _any_ | _any_ | Legacy retry shuffle (env-var gate) +| set | true | empty | _any_ | Legacy retry shuffle (no coordinator) +| set | true | populated | absent | Legacy retry shuffle (first attempt / no transition yet) +| set | true | populated | present | ROAST `EvaluateRoastRetryForSigning` +|=== + +The bundle is "present" once the elected coordinator's node has +produced a `TransitionMessage` at the end of a prior attempt +(see Phase 7.1 in RFC-21). Until that happens, the ROAST path is +dormant and the legacy path provides liveness. + +== Error handling discipline + +The orchestration layer distinguishes two error classes: + +* *Static-configuration errors.* Env var unset, no coordinator + registered, signer-material format not extractable. These are + deterministic per deployment configuration: every honest signer + observes the same outcome. Logged at INFO, signing flow + continues with the legacy retry shuffle. + +* *Runtime state-machine errors.* `Coordinator.BeginAttempt` + failures, internal invariant violations, + `ErrAttemptInfeasible` from the policy's threshold floor. These + are non-deterministic across nodes. Treated as *hard failures*: + the session is declared failed and the operator is notified + via the standard signing-failure log path. Falling back to + legacy on these errors would let one node use legacy retry + while another uses ROAST, which would split the signing group + on `NextAttempt` agreement. + +This discipline is the load-bearing safety property of the +RFC-21 design and is enforced in +`pkg/frost/signing/roast_retry_executor_entry_frost_native.go`. + +== Production rollout sequencing + +. *Build the binary with the tag.* CI exercises the tagged path on + every pull request that touches Go, so the ROAST retry flow cannot + silently rot: ++ +-- +* The `client-frost-roast-retry` job in `.github/workflows/client.yml` + compiles the coordinator flow with cgo disabled + (`go build -tags "frost_roast_retry" ./...` and + `go build -tags "frost_native frost_roast_retry" ./...`) and runs the + tagged unit tests under the three non-cgo tag sets that cover the + matrix — `frost_native`, `frost_roast_retry`, and + `frost_native frost_roast_retry` — via + `go test -tags "" ./pkg/frost/... ./pkg/tbtc/...` against the + mock FFI (no `libfrost_tbtc` required). +* `.github/workflows/frost-cgo-integration.yml` adds `frost_roast_retry` + to its real-crypto cgo tag set, runs the whole tagged + `./pkg/frost/signing/` suite against the linked `libfrost_tbtc` with + skips forbidden, and smoke-builds the activation artifact via + `make build-frost`. +-- ++ +Production binaries are produced with `make build-frost` (tags + `frost_native frost_tbtc_signer frost_roast_retry`, cgo-linked against + `libfrost_tbtc`); the default `make build` still ships the + `!frost_roast_retry` no-op stubs. Production adopts the tagged build + once the readiness manifest at + `docs/development/frost-readiness-manifest.adoc` flips to + `present`. (The manifest was originally planned for the tBTC + monorepo's `docs/operations/` directory; the monorepo signer is + retired and keep-core is canonical, so the manifest lives in this + repository.) +. *Verify FROST/UniFFI V1 migration.* The DKG-pubkey extraction + helper rejects FrostUniFFIV1 signer material. The Phase 7 + manifest flip is gated on verified migration off V1 across + production signers; until that migration completes, ROAST + retry would silently fall back to legacy on V1-bearing nodes. +. *Stage operator opt-in.* Operators set + `KEEP_CORE_FROST_ROAST_RETRY_ENABLED=true` on a subset of nodes + first. Static-configuration fallback guarantees mixed-state + deployments stay correct: nodes without the env var simply use + legacy. Beware: a node with the env var set but no registered + coordinator (e.g., due to a misconfigured startup script) still + uses legacy, so the safety property holds. +. *Monitor for runtime hard-failures.* The "ROAST orchestration" + log lines under + `keep-frost-roast-orchestration` and + `keep-frost-roast-retry` loggers indicate transitions of the + new state machine. A spike in WARN/ERROR entries from these + loggers is the early signal of trouble. +. *Roll back via env var.* If anything misbehaves, unset + `KEEP_CORE_FROST_ROAST_RETRY_ENABLED` and restart (or wait for + the per-call check to flip the next attempt). The legacy code + paths are retained through Phase 6 and 7 deliberately to make + this rollback bit-for-bit safe. + +== Interim liveness alerting (RFC-21 Annex B) + +Until Phase-7 t-of-included finalize removes the per-attempt veto, +the serial retry loop's liveness envelope assumes `f <= 2` byzantine +members (see RFC-21 Annex B for the sampling arithmetic). The node +exports three gauges via the `/metrics` endpoint implementing the +annex's required alerting. The names below are the exported series +names: the metrics registry prepends the `performance_` application +prefix to every gauge it registers, so alert queries must use the +prefixed form. + +[cols="2,3"] +|=== +| Exported gauge | Meaning + +| `performance_signing_attempt_rolling_success_rate` +| Per-attempt success rate over the last 50 terminal attempt + outcomes observed by this node (all wallets aggregated; one + observation per network-wide attempt). + +| `performance_signing_attempt_rolling_sample_count` +| Number of outcomes currently in the rolling window (caps at 50). + +| `performance_signing_attempt_implied_f_alert` +| `1` when the window is full and the success rate is below `0.14` - + observed failure rates imply `f >= 3` behaviour under the Annex B + model. Alert on this value being sustained, not on a single + scrape: the window slides per attempt, so consecutive scrapes are + correlated. +|=== + +For independent full windows the default threshold false-alarms on +roughly 4% of windows at a true `f = 2` and detects roughly 64% of +windows at `f = 3` (97% at `f >= 4`; the staggered-silence profile +that fails attempts deterministically is detected with certainty). +Benign attempt failures (network flakes, restarts) depress the rate +below the model's prediction, so once the Phase 5 baseline +calibration worksheet records the benign failure rate, revisit the +threshold (`SigningLivenessAlertSuccessRateThreshold` in +`pkg/clientinfo/signing_liveness.go`). The alert is advisory: its +operational response is the Annex B escalation path (investigate +member inactivity, operator-inactivity claims, wallet retirement), +not an automatic rollback. + +== FROST DKG digest format coupling + +The FROST DKG result digest is a cross-repo wire-format contract +shared by keep-core, the tBTC TypeScript test vectors, and the +on-chain `FrostDkgValidator.resultDigest(...)` implementation. +The current tag is `tbtc-frost-dkg-result-v1`. + +Treat that string, the ABI field order, and the field types as a +single coupled interface. A future wire-format migration, for +example changing the digest fields or their order, must update +keep-core, the tBTC fixture/emitter, and the on-chain validator +together. Do not change the string on one side as an isolated +cleanup. + +== Cross-references + +* RFC-21: `docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc` +* Readiness manifest: `docs/development/frost-readiness-manifest.adoc` +* Build-tag scaffolding: `pkg/frost/signing/roast_retry_registration_*.go` +* Orchestration entry point: `pkg/frost/signing/roast_retry_orchestration.go` +* Executor-adapter wiring: `pkg/frost/signing/native_ffi_executor_adapter.go` +* Signing-loop dispatcher: `pkg/tbtc/signing_loop_roast_dispatcher.go` +* ROAST-driven selector: `pkg/tbtc/signing_loop_selector_frost_roast_retry.go` +* Bundle registry: `pkg/frost/signing/roast_retry_bundle_registry_*.go` +* Readiness env var: `pkg/frost/signing/roast_retry_readiness.go` +* Coordinator state machine: `pkg/frost/roast/coordinator_state.go` +* Adapter type: `pkg/frost/roast/signing_retry_adapter.go` diff --git a/docs/rfc/rfc-20-schnorr-frost-migration-scaffold.adoc b/docs/rfc/rfc-20-schnorr-frost-migration-scaffold.adoc new file mode 100644 index 0000000000..7120c2d9c9 --- /dev/null +++ b/docs/rfc/rfc-20-schnorr-frost-migration-scaffold.adoc @@ -0,0 +1,49 @@ += RFC-20: Schnorr/FROST Migration Scaffold + +*Author:* Threshold Labs +*Status:* Draft +*Date:* 2026-02-19 + +== Summary + +This RFC introduces the initial keep-core scaffolding for migrating tBTC from +threshold ECDSA signatures to Schnorr/FROST signatures. + +This change does not switch runtime signing logic yet. It defines core data +types and compatibility helpers required by follow-up protocol, chain, and +wallet orchestration changes. + +== Initial Deliverables + +* New `pkg/frost` package with: +** Taproot x-only output key type (`OutputKey`) +** BIP-340 Schnorr signature type (`Signature`) +** Serialization and logging helpers for Schnorr signatures +** Legacy compatibility alias helper: +`HASH160(0x02 || xOnlyOutputKey)` + +== Compatibility Model + +FROST wallets are expected to use 32-byte x-only keys as canonical identifiers. +During migration, legacy 20-byte wallet key hash paths are supported via +compatibility alias: + +---- +walletPubKeyHashCompat = HASH160(0x02 || xOnlyOutputKey) +---- + +== Follow-up Work + +1. Add FROST signer and coordinator interfaces to replace `pkg/tecdsa/signing`. +2. Introduce FROST DKG executor replacing GG18 pre-params and DKG wiring. +3. Update tBTC chain interfaces and wallet registry integration to accept + x-only keys as canonical wallet identities. +4. Update Bitcoin transaction builders to support P2TR key-path spends. +5. Add dual-stack runtime routing: GG18 existing wallets + FROST new wallets. +6. Add full integration tests for mixed wallet generations and migration flows. + +== Non-Goals (This RFC Revision) + +* No production FROST coordinator implementation. +* No on-chain contract ABI migration in this repository. +* No replacement of existing GG18 runtime paths yet. diff --git a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc new file mode 100644 index 0000000000..d66bfd2218 --- /dev/null +++ b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc @@ -0,0 +1,1088 @@ += RFC-21: ROAST Coordinator, Retry, and Transition Evidence + +*Author:* Threshold Labs +*Status:* Draft +*Date:* 2026-05-22 + +== Summary + +This RFC defines the protocol layer that lets keep-core honestly advertise its +FROST signing path as ROAST-compliant. Today the package layout names ROAST +concepts (`pkg/frost/roast`, `pkg/frost/retry`) but the actual semantics fall +short of the protocol in two specific places: + +* The retry policy in `pkg/frost/retry/retry.go` is byte-identical to the + tECDSA shuffle in `pkg/tecdsa/retry/retry.go`. It is a deterministic + participant shuffle, not ROAST-aware attempt advancement. +* The three FFI/native-FROST receive loops in `pkg/frost/signing/` drop + channel overflows with `select { default }`, with no bounded transition + evidence and no retransmission contract. + +This RFC proposes a layered design that closes both gaps together, because +they share the same notion of *attempt context* and *transition evidence*. +It is broken into discrete PR-sized phases so the migration can land +incrementally without regressing the existing signing flow. + +== Motivation + +The ROAST paper (Ruffing-Ronge-Aranha-Schneider, CCS 2022) describes a +coordinator-driven retry protocol that turns FROST's brittle round +synchronisation into an asynchronous robust signing primitive. The key +invariants are: + +1. *Attempt context.* Every signing attempt is bound to a deterministic + context (session, key group, message digest, attempt counter, included + participant set). All in-flight protocol messages must reference the + attempt context they belong to. Messages for a stale or future attempt + must not influence the current attempt's transcript. +2. *Transition evidence.* When the coordinator moves an attempt forward it + must publish (or be able to publish on demand) evidence that justifies + the transition: which contributions arrived, which were rejected, which + peers failed to respond within the attempt's bound, and what new + exclusion set the next attempt should use. This is what makes the + protocol *robust* rather than relying on optimistic liveness. +3. *Deterministic exclusion.* The next attempt's participant set is a pure + function of the previous attempt's transition evidence (plus the + original group + seed). Two honest coordinators driving the same session + must arrive at the same attempt context. + +The byte-identical `EvaluateRetryParticipantsForSigning` shuffle satisfies +none of these. It re-shuffles the same set deterministically using +`(seed, retryCount)`. It has no notion of which participants were +*blamable* in the previous attempt, no exclusion ledger, and no message +context binding. + +The receive-loop drop is more subtle but equally protocol-violating: a +silent drop on channel overflow means that two participants observing the +same network can end up with divergent transcripts -- one with the +contribution, one without -- and there is no evidence trail to detect or +recover from that divergence. The `select { default }` pattern is fine for +optimistic transport but not as the canonical mechanism for protocol +membership. + +The two findings cluster naturally: + +* M4 (the receive drop) is the source of evidence. +* M7 (the retry shuffle) is the consumer of evidence. + +A change that fixes M7 without M4 has nothing to drive retry decisions on. +A change that fixes M4 without M7 produces evidence that no consumer reads. +This RFC therefore treats them as one design split into phases, not as two +independent fixes. + +== Background: ROAST in brief + +For implementers approaching this RFC fresh, the relevant ROAST surface is: + +* A *session* fixes the key group, the message digest, and the original + signer set. +* Each session goes through one or more *attempts*. An attempt is + identified by `(session_id, attempt_number)` and contains an *included + set* of participants and an *excluded set* of participants known to be + unable or unwilling to complete this attempt. +* The *coordinator* of an attempt is selected deterministically from the + included set (this is already implemented in `pkg/frost/roast/coordinator.go` + via `SelectCoordinator`). +* The coordinator collects round-one commitments, round-two signature + shares, then either: +** Aggregates a signature when t-of-n shares arrive within the attempt's + time bound -- the session is done. +** Times out and emits *transition evidence*: the set of peers that did + not contribute on time, and the new excluded set the next attempt + should use. + +The retry shuffle in keep-core's tECDSA path predates ROAST and answers a +different question -- "if signing fails, who do we try next?". It does so +without distinguishing inactive peers from corrupted ones, and it makes no +attempt to construct an evidence trail. That is appropriate for tECDSA +(which has its own malicious-share detection downstream) and inappropriate +for FROST (which expects the coordinator to be the source of truth for +attempt advancement). + +== Current state + +=== Retry layer + +`pkg/frost/retry/retry.go` exports `EvaluateRetryParticipantsForSigning` +and `EvaluateRetryParticipantsForKeyGeneration`. Both are pure shuffles +seeded by `(seed, retryCount)`. The signing variant takes no input from +the previous attempt's transcript. `diff pkg/frost/retry/retry.go +pkg/tecdsa/retry/retry.go` is empty. + +=== Coordinator layer + +`pkg/frost/roast/coordinator.go` exports `SelectCoordinator`. The function +is correct in isolation -- given an included set and an attempt context it +returns a deterministic coordinator -- but there is no consumer of the +selected coordinator's state. Attempt context is reconstructed +implicitly from `(seed, retryCount)` at the retry layer, with no shared +record of which messages arrived in which attempt. + +=== Receive layer + +`pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go` and +`pkg/frost/signing/native_frost_protocol_frost_native.go` together host +three receive loops: + +* `native_ffi_primitive_transitional_frost_native.go:973` -- tbtc-signer + round contribution capture. +* `native_frost_protocol_frost_native.go:568` -- native FROST round-one + commitments. +* `native_frost_protocol_frost_native.go:650` -- native FROST round-two + signature shares. + +All three use the same shape: + +[source,go] +---- +messageChan := make(chan *T, expectedMessagesCount*4+1) +request.Channel.Recv(recvCtx, func(message net.Message) { + // shouldAcceptNativeFROSTMessage(...) filtering ... + select { + case messageChan <- payload: + default: + } +}) +---- + +The channel is generously sized (`expected*4+1`), and the assembly side +applies first-write-wins / equal-or-reject (added in PR #3959). But the +`default` arm is still a *silent drop*. When it triggers, the protocol +has no trail to point to: no log of the dropped sender, no count of +drops by sender, no signal to the coordinator that this peer is being +under-represented. + +== Proposed design + +The design is three layers tied together by a single shared *attempt +context* type. + +=== Shared types + +A new package `pkg/frost/roast/attempt` introduces: + +[source,go] +---- +type AttemptContext struct { + SessionID string + KeyGroupID string + MessageDigest [32]byte + AttemptNumber uint + IncludedSet []group.MemberIndex + ExcludedSet []group.MemberIndex + AttemptSeed [32]byte +} + +func (AttemptContext) Hash() [32]byte +---- + +`AttemptContext.Hash()` becomes the canonical binding for every protocol +message emitted in the attempt -- contribution messages already carry a +`SessionIDValue`; we extend them with an `AttemptContextHash` field so +the receiver can reject stale-attempt messages structurally instead of +relying on session ID alone. + +`AttemptSeed` is widened from `int64` to `[32]byte` and *must* be +derived from inputs the group already agrees on -- specifically: + +[source,go] +---- +AttemptSeed = SHA256( + DkgGroupPublicKey || SessionID || MessageDigest, +) +---- + +This binding prevents a malicious coordinator from picking a seed that +shapes the included set in its favour. The seed is a pure function of +session inputs; it is never chosen, only derived. Any signer can +recompute it from the session header and verify the coordinator's +participant selection. + +*`DkgGroupPublicKey` source.* The runtime extracts `DkgGroupPublicKey` +from the FFI signer material at attempt construction time -- the same +material that already carries the DKG-validated group public key and is +required at signature-verification time anyway. Do not re-read it from +the wallet registry: the FFI material is the canonical hot-path source, +removes async/DB lookup latency, and preserves separation between the +core signing protocol and application state. + +=== Layer A: Receiver transition evidence (M4) + +The three `select { default }` drops become: + +[source,go] +---- +select { +case messageChan <- payload: +default: + evidence.RecordOverflow(payload.SenderID(), attemptCtx) +} +---- + +`evidence` is an `AttemptEvidenceRecorder` instantiated per attempt by +the coordinator-aware caller. It tracks: + +* Overflow events keyed by sender -- a sender that overflows + repeatedly is suspect either of attack or of being on a degraded + link, and the next attempt should treat the channel as evidence + rather than dropping it. +* Reject events keyed by sender and reason + (`shouldAcceptNativeFROSTMessage` returning false already). +* First-write-wins conflicts keyed by sender -- already logged in + PR #3959 but not yet structured into evidence. +* Per-attempt time bound expiry -- which senders failed to respond at + all before the attempt's context deadline. + +The recorder produces a `TransitionEvidence` value when the attempt +completes (either by signature aggregation or by timeout), which the +coordinator consumes. The recorder itself never decides who is excluded; +it only collects. + +Bounded means bounded: the recorder has a fixed-size ring *per sender, +per blame category*. The categories are tracked with separate quotas so +one category cannot mask another -- a peer cannot spam overflow events +to drown out reject evidence or vice-versa: + +[source,go] +---- +type categoryQuota struct { + Overflow uint8 // default 8 + Reject uint8 // default 8 + Conflict uint8 // default 4 + Silence uint8 // default 1 (single bit per attempt deadline) +} +---- + +The point is to produce a fixed-size attestation, not to log +everything forever. Per-attempt evidence is at most +`O(|IncludedSet| * sum(quotas))` bytes -- bounded, predictable, and +small enough to be signed and broadcast as a single message. The +broadcast mechanism is the coordinator-aggregated `TransitionMessage` +defined in the Resolved decisions section. + +=== Layer B: Coordinator state (joining M4 and M7) + +`pkg/frost/roast/coordinator.go` grows from a single selection function +into a state machine: + +[source,go] +---- +type AttemptState int +const ( + AttemptPending AttemptState = iota + AttemptCollecting + AttemptAggregating + AttemptSucceeded + AttemptTransitioned +) + +type Coordinator interface { + BeginAttempt(ctx AttemptContext) (AttemptHandle, error) + RecordEvidence(handle AttemptHandle, evidence TransitionEvidence) error + NextAttempt(handle AttemptHandle) (AttemptContext, error) +} +---- + +`NextAttempt` is the policy function that produces the next attempt's +context from the previous attempt's evidence. It is deterministic given +`(AttemptContext, TransitionEvidence)` -- two coordinators with the same +verified inputs agree on the next attempt without further coordination. + +The verified-inputs requirement is critical: gossip is eventually +consistent, but `NextAttempt` is a synchronous state transition. Two +honest signers fed differently-timed evidence sets produce divergent +contexts. To prevent that, the *evidence input itself* is an +authoritative `TransitionMessage` produced by the current attempt's +coordinator (the "coordinator-aggregation" model defined in the +Resolved decisions section); see that section for the full +agreement-flow specification. + +*Seed-bridging.* The legacy `pkg/frost/roast/coordinator.go::SelectCoordinator` +helper accepts an `int64` seed plus an attempt number. `BeginAttempt` +wraps it with a sterile bridge that folds the new `[32]byte` +`AttemptSeed` into the legacy parameter shape -- for example, taking +the first 8 bytes as a big-endian `int64`. The bridge is a +non-cryptographic adapter for the deterministic shuffle: equivalent +seed bytes must produce the same legacy `int64` on every honest +signer. The bridge is named, isolated, and exhaustively tested so +later edits cannot accidentally desynchronise it. + +The exclusion policy (verifiable-blame revision) starts from one +observation: every evidence entry in the bundle is an observer-signed +*claim*, not a self-incriminating proof. Nothing in an overflow, +reject, or conflict counter lets a third party re-check that the +accused actually misbehaved. A policy that permanently excludes on +unverifiable counters inverts ROAST's robustness guarantee -- a single +byzantine observer could fabricate evidence against honest members +and grind the `IncludedSet` toward `ErrAttemptInfeasible`. Permanent +exclusion therefore requires the accusation to be *established*: +corroborated by a quorum of distinct accusers large enough that, under +the protocol's own t-of-n honesty assumption, at least one accuser is +honest. + +. Accusation tallying: each bundle snapshot is one observer's claim + set. Only observers in the previous attempt's `IncludedSet` are + credible; each observer counts at most once per accused member per + category regardless of the claimed count magnitude; accusations + against members outside the original signer set are ignored. An + accusation is established when at least + `ExclusionAccuserQuorum(originalGroupSize, threshold) + = originalGroupSize - threshold + 1` (that is, `f+1`) distinct + observers make it. A real fault is observed by every honest member + processing the broadcast, so established faults reach the quorum + naturally; fabricated ones cannot, because at most `f` members are + byzantine. +. Established reject or conflict accusations (validation / + equivocation blamable) move the accused to `ExcludedSet` + permanently. The categories are tallied independently and never + summed with each other. +. Established overflow accusations (transport blamable) move the + accused to the *parked* set for one attempt -- never to + `ExcludedSet`. Transport pressure is observable only at the + transport layer and can never be made self-incriminating, so + overflow may cost an attempt of liveness but not permanence. +. Senders with deadline-expiry only -- silent peers -- are moved to a + *parked* set that the next attempt skips but the attempt after that + retries (to tolerate transient outages). Parking is *strictly + transient*: a single attempt's worth of skip, no escalation. A peer + falsely labelled silent because their contribution arrived late (or + because a malicious coordinator censored it) is not permanently + penalised -- they are reinstated by the very next attempt. + Silence is detected as *bundle absence* (the member submitted no + evidence snapshot for the transition). A member that submits its + snapshot while withholding its signing contribution is therefore + not parked by this policy: signing-silence is invisible to the + transition layer, costs the attempt, and is bounded only by the + retry budget (Annex B) until t-of-included finalize lands. +. If `IncludedSet` minus exclusions drops below the threshold `t`, the + coordinator returns `ErrAttemptInfeasible` and the session is + declared failed for this signer set. + +The quorum is a *derived constant* of the key-group shape -- for the +production 51-of-100 group it is 50 -- so honest signers do not need +to negotiate it and drift cannot desynchronise it. Sub-quorum claims +are deliberately ignored rather than parked: acting on a single +unverifiable claim would let one byzantine observer cost any honest +member an attempt of liveness at will. + +*Verifiability roadmap.* Permanent exclusion on a *single* piece of +evidence becomes sound once the wire format carries +self-incriminating proof: for conflicts, the accused's own two +operator-signed payloads with identical (attempt, sender) and +different bytes; for rejects, the accused's operator-signed +contribution plus a deterministic validation failure any member can +re-run. When those land, the per-category quorum gate can be relaxed +to proof-verified entries. The cost of the interim quorum policy is +bounded: a fault observed by fewer than `f+1` honest members (for +example a targeted, per-recipient equivocation) is not permanently +excluded and instead burns retry attempts, which the serial-attempt +latency analysis already budgets for. Near the assumption boundary +the gate is intentionally demanding -- at worst-case `f` only `t` +honest observers exist, so establishment needs all but `2t-n-1` of +them (50 of 51 at the production shape) to have observed the fault +and landed snapshots in the bundle. In that regime the quorum acts +as a fabrication firewall rather than a working exclusion mechanism; +restoring per-category exclusion under heavy attack is exactly what +proof-carrying blame is for. + +=== Layer C: Retry orchestration (M7) + +`pkg/frost/retry/retry.go` is renamed to +`pkg/frost/retry/retry_legacy.go` and kept for the key-generation path +(which already has its own three-tier exclusion structure that is closer +to ROAST semantics). The signing path moves to a thin wrapper around +`Coordinator.NextAttempt`: + +[source,go] +---- +func EvaluateRoastRetryForSigning( + coordinator Coordinator, + handle AttemptHandle, +) ([]group.MemberIndex, AttemptContext, error) +---- + +The byte-identical-to-tECDSA `EvaluateRetryParticipantsForSigning` is +removed once all callers migrate. We keep a `roast.SigningRetryAdapter` +shim implementing the old signature that delegates to the coordinator, +to make the migration mechanical PR-by-PR. + +== Phased implementation + +Each phase is one or two PRs. Phases are linear: later PRs assume +earlier PRs have merged. + +=== Phase 0: This RFC + +Doc-only. Lands first so subsequent code PRs can reference its design +choices in their PR descriptions and reviews. + +=== Phase 1: Attempt context type and hash + +* Add `pkg/frost/roast/attempt` package with `AttemptContext` and + canonical hash. No protocol behaviour changes. +* Extend protocol message structs with `AttemptContextHash` field, with + the field optional during the migration so existing peers stay + compatible. + +=== Phase 2: Receiver overflow tracking (M4 layer A) + +* Introduce `AttemptEvidenceRecorder` interface and a no-op default. +* Plumb the recorder through the three receive loops. Default no-op + preserves exact current behaviour. +* Add unit tests showing the recorder captures overflow without + changing receive semantics in the noop path. + +=== Phase 3: Coordinator state machine + +* Promote `pkg/frost/roast/coordinator.go` to a state-tracking + coordinator. Existing `SelectCoordinator` becomes an internal helper. +* Cover deterministic next-attempt computation under unit tests with + property tests for the + `(AttemptContext, TransitionEvidence) -> AttemptContext` map. +* No production code path uses the new coordinator yet -- it ships + unused. + +=== Phase 4: Wire receiver to coordinator + +* Connect the evidence recorder to a real coordinator instance behind + a new build tag (`frost_roast_retry`). +* Existing receive loops still use the noop recorder; the new code + path is reachable only when the build tag is set. +* Add a soak-style test that drives the full attempt -> evidence -> + next-attempt loop under fault injection (synthetic overflow, + synthetic reject, synthetic silence). + +=== Phase 5: Retry adapter, session orchestration, readiness gate + +* Add `EvaluateRoastRetryForSigning` and `roast.SigningRetryAdapter` + with a `ChainAddressResolver` interface to bridge + `group.MemberIndex` (ROAST namespace) to `chain.Address` (legacy + namespace). +* Wire session orchestration at the layer that constructs + `NativeExecutionFFISigningRequest`: at session start call + `Coordinator.BeginAttempt` and `SetCurrentAttemptHandleForSession`; + on session end call `ClearCurrentAttemptHandleForSession` from a + deferred cleanup so success and failure paths converge. +* Add a TTL eviction sweep over `sessionAttemptBindings` (default + two hours) so a goroutine panic before the deferred clear cannot + leak bindings indefinitely (see Resolved decisions). +* Wire a feature-flagged readiness gate + (`KEEP_CORE_FROST_ROAST_RETRY_ENABLED=true`) so production builds + with the `frost_roast_retry` tag still refuse to wire orchestration + without explicit operator opt-in. The gate matches the precedent + set by `KEEP_CORE_FROST_TBTC_SIGNER_ACCEPT_SCAFFOLD_KEY_GROUP`. + +*Important:* Phase 5 ships *no* signing-call-site migrations. The +adapter exists and is fully wired, but no production receive loop +calls it yet. A partial migration (round-one on ROAST, round-two on +legacy shuffle within the same session) would fracture the +attempt-context binding across the two rounds and disconnect the +evidence captured in round-one from the participant selection in +round-two. The readiness gate -- not partial migration -- is the +risk-management mechanism. + +=== Phase 6: Migrate all signing call sites + +*Status (2026-06-12): the call-site migration is complete.* The plan +below originally named three receive loops; the tree evolved under +it and the honest accounting is: + +* `collectBuildTaggedTBTCSignerRoundContributionMessages` - the only + receive loop with a production consumer - is migrated: it takes + its `EvidenceRecorder` from the roast-retry registry + (`roastRetryRecorderForCollect`, NoOp fallback preserving Phase 2 + semantics when nothing is registered), binds contributions to the + session's attempt context via the `attemptContextHash` message + field, and submits its end-of-collect snapshot through + `submitSnapshotIfActive` into `Coordinator.RecordEvidence`. +* `collectNativeFROSTRoundOneMessages` and + `collectNativeFROSTRoundTwoMessages` were *deleted*, not migrated: + commit `1c692bf03` (2026-06-07, "Remove unreachable generic FROST + protocol scaffolding") removed the generic two-round exchange they + belonged to, because no production flow reached it - + `frost-uniffi-v1` payloads execute on the legacy bridge and + `frost-tbtc-signer-v1` uses the coarse signing flow. The + single-coordinated-change constraint ("the three flows share one + attempt context per session") is therefore satisfied by + subtraction: exactly one flow exists, and it is fully bound. +* The surrounding orchestration shipped with the migration: the + executor adapter's Phase 6.3 entry + (`attemptRoastRetryOrchestrationFromRequest` → + `BeginOrchestrationForSession`), the static-vs-runtime error + taxonomy, the Phase 7.1 coordinator-side bundle production + (`maybeProduceTransitionBundle` in the orchestration cleanup), and + the Phase 7.2 bundle-consuming participant selector + (`roastSigningParticipantSelector` behind the `frost_roast_retry` + tag, legacy-shuffle fallback when no bundle is present). + +Two exit items remain deliberately open, gated on Phase 7's manifest +flip - not on more code: + +* Once the legacy `EvaluateRetryParticipantsForSigning` has no + callers, delete it. (Key-generation legacy retry stays.) It still + has one caller by design: `legacySigningParticipantSelector` is + the retained rollback path through Phases 6-7. +* Remove the `frost_roast_retry` build tag; the new retry path is + unconditional once Phase 7's manifest gate flips. + +=== Phase 7: Readiness manifest evidence + +* Update the FROST readiness manifest to flip ROAST retry + + transition evidence from `missing-no-go` to `present` once Phase 6 + ships and the integration test suite has been run against a real + testnet. +* The manifest lives at + `docs/development/frost-readiness-manifest.adoc` in this + repository. (It was originally planned for the tBTC monorepo's + `docs/operations/` directory; the monorepo signer is retired and + keep-core is canonical, so the manifest moved here with the same + update discipline.) +* As with every readiness gate in this repo, the manifest is updated + only when the supporting evidence is attached. The RFC does not + promise an early flip. + +== Resolved decisions + +The decisions in this section were settled in a Phase-3 design review +(2026-05-22) with cross-team protocol-owner input. They are listed +here so subsequent implementation PRs can reference them. + +=== Cross-process coordinator agreement + +*Decision: coordinator-proposed aggregation on a dedicated topic, +signed with the operator key, with receiver-side bundle verification +for censorship detection.* + +The earlier draft of this RFC carried "all-to-all signed-evidence +gossip with local union" as the recommended path. That recommendation +silently assumed gossip is synchronously consistent across the signer +set; in practice gossip is eventually consistent, so two honest +signers can hold divergent evidence sets at the moment the attempt +times out. Applying the deterministic `NextAttempt` function to +divergent inputs produces divergent next-attempt contexts and +fractures the signing group. + +The replacement flow is: + +. *Observation.* Each signer's `EvidenceRecorder` (Phase 2) + produces a per-attempt local-evidence snapshot. +. *Submission.* Each signer signs its snapshot with its operator + key (the same key `pkg/net` already uses to attribute network + messages) and broadcasts it on a dedicated evidence topic. +. *Aggregation.* The current attempt's elected coordinator + (the deterministic `SelectCoordinator` output) collects the + signed snapshots, builds a canonical bundle, signs the bundle, + and broadcasts it as a `TransitionMessage`. +. *Verification.* Every receiver validates the bundle's + coordinator signature, validates each contained snapshot's + operator signature, *and verifies that its own observations + appear in the bundle*. A coordinator that omits an honest + peer's signed snapshot is caught here. +. *Transition.* Receivers feed the verified bundle into + `NextAttempt`. Because the bundle is the authoritative input, + all honest receivers compute the same next-attempt context. + +A peer that signs conflicting snapshots is slashable -- the +signature is the binding. A coordinator that signs an inconsistent +bundle (omits observations, alters counts, etc.) is detected at +verification step (4) and the next-attempt coordinator handles the +exclusion. + +Alternatives considered (rejected): + +. *All-to-all signed-evidence gossip with local union.* Original + recommendation. Rejected because gossip's eventual-consistency + semantics let honest signers reach the deterministic + `NextAttempt` boundary with divergent inputs, producing + divergent outputs. +. *Piggy-back on existing FROST broadcast channel.* Rejected + because it couples evidence rate limits to protocol round-trip + rate limits, and re-uses a topic with different traffic + characteristics. +. *Coordinator-only authoritative without aggregation.* Rejected + because losing the all-signer signed attestations also loses + the audit trail. The aggregation model keeps the per-signer + signatures inside the bundle, so the audit trail survives. + +Liveness: a malicious coordinator can withhold the +`TransitionMessage`, stalling the transition. ROAST handles this +the same way it handles a malicious signer: the attempt times +out, the next attempt elects a different coordinator (the +`SelectCoordinator` output is deterministic but rotates with the +attempt number), and the new coordinator drives the transition. +The malicious coordinator's evidence is itself parked or +excluded by the new coordinator's bundle, ending the loop. + +Safety: any honest signer that verifies a bundle and computes +`NextAttempt(ctx, bundle)` produces the same context as any other +honest signer that verifies the same bundle. Safety reduces to +"is the bundle correctly verified" -- a local check, not a +network-consistency requirement. + +This design satisfies the formal verified-inputs requirement of +the deterministic `NextAttempt` policy specified in Layer B. + +=== Source of `DkgGroupPublicKey` for the seed + +*Decision: extract from FFI signer material at attempt construction.* + +The DKG-validated group public key is already present in the FFI +signer material (it is required at signature-verification time +anyway), so the seed derivation can take it from there. The +wallet registry is *not* consulted on the hot path; doing so +would introduce async lookup latency and entangle the core +signing protocol with application state. See Shared types above +for the derivation contract. + +=== `AttemptContext` ↔ `NativeExecutionFFISigningRequest` binding + +*Decision: extend the request struct with an `AttemptContext` +field; the context is Go-side orchestration only.* + +The context does not cross the CGO/Rust boundary into the +`tbtc-signer` engine -- the engine remains a pure signing +primitive. Go-side coordinator wiring populates the context; +existing call sites construct attempt-zero contexts inline +during Phase 4. + +=== `SelectCoordinator` retention + +*Decision: keep the existing helper; bridge the seed type inside +`BeginAttempt`.* + +The deterministic shuffle is correct in isolation. The bridge +folds the new `[32]byte` `AttemptSeed` into the legacy `int64` +parameter shape with a sterile, named adapter (see Layer B). + +=== Evidence-signing key + +*Decision: reuse the existing operator key.* + +The operator key already binds every other gossip message a +keep-core node emits via `pkg/net`. Layering a second key +surface specifically for evidence signing is premature +optimization given the current key model. + +=== Evidence message format + +*Decision: signed-body protobuf envelopes +(`pkg/frost/roast/gen/pb/evidence.proto`), routed via the +`net.Message` interface.* + +Evidence signatures cover a domain-separated byte stream -- a +fixed per-message-type tag followed by the exact serialized body -- +while the body itself travels verbatim. A snapshot is +`SignedLocalEvidenceSnapshot{body, operator_signature}` where +`body` is the serialized `LocalEvidenceSnapshotBody` and the +operator signature covers `domain_tag || body`; a transition +message is `SignedTransitionMessage{body, coordinator_signature}` +whose body embeds every member's signed snapshot envelope verbatim +(`repeated bytes signed_snapshots`) and whose coordinator signature +covers `domain_tag || body`. Each `domain_tag` begins with byte +0x00 -- an illegal protobuf tag (field 0) -- so the signed payload +is undecodable as any protobuf message, and each message type uses +a distinct tag, so a signature over one body can never be accepted +for another (snapshot: `0x00 "roast/signed-evidence-snapshot/v1" +0x00`; transition: `0x00 "roast/signed-transition-message/v1" +0x00`; the Phase 7 signing package uses its own tag). The tag is +not carried on the wire -- only the body is. A verifier re-derives +the tag, checks the signature over the tag plus the body bytes it +received, and only then parses the body; nothing in the evidence +chain is ever re-encoded. Signature validity therefore never +depends on any serializer's canonical form -- across protobuf +library versions or across languages (the Phase 7 Rust signer +verifies and parses these same bytes). Producers marshal a body +exactly once, at signing time, and transmit those bytes. + +The original Phase 3 implementation used canonical JSON +(`json.Marshal` over field-order-stable structs) as the signed +encoding; it was replaced before any persisted or cross-component +evidence existed because canonical-JSON byte stability is a +Go-implementation accident, not a portable contract. + +=== Maximum evidence-message size + +*Decision: single `TransitionMessage` per transition; no +chunking.* + +Under coordinator-aggregation, the per-transition payload is +`O(N)` not `O(N^2)`. At a 100-signer group with all four +quotas saturated the encoded bundle is ~10-20 KiB, +comfortably within libp2p's per-message limits. + +=== Session-handle binding TTL eviction + +*Decision: a periodic sweep over `sessionAttemptBindings` +(introduced by Phase 4.3) evicts entries older than a fixed TTL +(default two hours).* + +Phase 4.3's session-handle registry expects the orchestration +layer (Phase 5) to call `ClearCurrentAttemptHandleForSession` +from a deferred cleanup when the session ends. A goroutine +panic before the deferred clear runs would leak the binding +permanently, since nothing else removes it. + +The two-hour TTL is a defence-in-depth backstop. It is long +enough that no real signing session reaches it (typical sessions +complete in seconds; a multi-attempt session under ROAST retry +should not exceed minutes), and short enough that a leaked +binding does not accumulate across days of node uptime. The +sweep itself runs on a background goroutine; entries are evicted +in batches under the registry's existing write lock. + +Phase 5.2 introduces both the sweep goroutine and the timestamp +field on `sessionAttemptBinding`. The eviction does not depend +on session-completion correctness; it only catches the +panic-before-defer pathological case. + +=== Orchestration error taxonomy + +*Decision: orchestration errors are bucketed into two classes, with +fundamentally different handling.* + +When the executor adapter calls `BeginOrchestrationForSession`, +the call can fail for two distinct reasons that the receiver MUST +distinguish: + +. *Static-configuration errors* -- the build was deployed without + the readiness env var, or no caller has registered a coordinator. + These errors are fully deterministic: every honest signer + observes the same error from the same configuration at the same + startup state. Safe to log at INFO and fall back to the legacy + retry path. Sentinel errors `ErrRoastRetryReadinessOptOut` and + `ErrNoRoastRetryCoordinatorRegistered` (introduced in Phase 6.3) + signal this class; `errors.Is` checks identify them. + +. *Runtime state-machine errors* -- `Coordinator.BeginAttempt` + returned an error (out-of-memory, malformed AttemptContext, + internal invariant violated, etc.). These errors are + non-deterministic across nodes: node A may experience a runtime + failure while node B succeeds. Treat them as hard failures: + return an error from the executor adapter, declare the session + failed. + +The safety reason is load-bearing. If node A falls back to the +legacy retry shuffle while node B proceeds with the ROAST state +machine, the two nodes compute different `NextAttempt` participant +sets, and the signing group fractures permanently. The legacy +fallback is only acceptable when every honest signer would make +the same choice, which is true for static configuration and false +for runtime errors. + +This decision applies to Phase 6.3 (orchestration wiring at the +executor adapter) and Phase 6.4 (call-site migration). Phase 5 +deliberately ships orchestration as best-effort because it has no +production consumer; Phase 6 is where the safety distinction +matters. + +=== `FrostUniFFIV1` signer-material prerequisite + +*Decision: Phase 7's manifest flip is gated on verified migration +away from `FrostUniFFIV1` signer material across all production +signers.* + +Phase 6.1's `ExtractDkgGroupPublicKeyFromMaterial` switches on +`NativeSignerMaterial.Format`. The two production-relevant formats +(`FrostUniFFIV2` and `FrostTBTCSignerV1`) expose the DKG group +public key on the material directly. The legacy `FrostUniFFIV1` +format does not include the group key in a form Phase 6.1 can +extract; the helper returns a descriptive error directing +operators to migrate. + +Until the network has fully migrated off V1, the Phase 7 readiness +manifest cannot flip to `present`. The migration tracking +mechanism is out of scope for this RFC; the prerequisite is +documented here as a hard dependency of Phase 7. + +== Open questions + +. *Persistence across signer restart.* If a signer crashes mid-attempt, + does it lose its evidence? The paper assumes persistent state. For + keep-core we likely accept evidence loss on restart at first (the + attempt times out and a new attempt is started fresh) and revisit + persistence in a follow-up RFC once we have wire-level evidence. ++ +*Sketch for Phase 5+:* introduce a `SyncState` gossip message. A +restarting node broadcasts +`(LastKnownAttemptContextHash, KeyGroupID)`; peers reply with their +current attempt and the set of signed attestations they hold for +that attempt. This avoids the timeout-and-restart cost on graceful +redeploys without requiring on-disk persistence -- the peers' +gossiped attestations *are* the persistent record. +. *FFI surface.* `tbtc-signer` (the Rust engine) does not need to know + about ROAST coordinator state -- it remains a pure signing engine. + But it does need to surface structured errors that the coordinator + can map to exclusion reasons. PR #425 / #3961 (the L5 paired + change) is the template for this style of error-code wiring. Future + exclusion-relevant errors should follow the same dedicated-variant + pattern. +. *Backward-compat horizon.* Once the `AttemptContextHash` field is on + protocol messages, how long do we accept messages from peers that + omit it? Proposal: optional during Phase 1-5, required at Phase 6, + validation-rejection at Phase 7. + +== Out of scope + +* DKG retry. The key-generation legacy retry stays. Re-evaluating DKG + retry under ROAST is a separate RFC. +* Bitcoin transaction-builder changes. Witness restoration and + P2WSH/P2TR handling are unaffected. +* Operator UX (CLI flags, dashboards). Whatever is needed lands + alongside Phase 5 / Phase 6 as small, focused PRs. +* Cross-domain ROAST (e.g., between keep-core and tbtc-signer). The + signer remains a single-process engine; coordinator state lives on + the keep-core side. + +== Annex A (normative): coordinator-shuffle seed derivation + +This annex is the single normative definition of the coordinator- +shuffle seed. Two independent implementations exist -- the Go RFC-21 +layer (`pkg/frost/roast`) and the Rust tbtc-signer attempt-context +validation (`pkg/tbtc/signer/src/engine.rs`) -- and both MUST derive +byte-identical values from this text. Conformance is pinned by the +shared vector file regenerated from the Go implementation +(`pkg/frost/roast/testdata/coordinator_seed_vectors.json`, mirrored +byte-identically to `pkg/tbtc/signer/testdata/`); any divergence +fails the drifting side's own unit suite. + +=== Inputs + +* `KeyGroupBytes`: the UTF-8 bytes of the canonical FROST key-group + handle. For `FrostTBTCSignerV1` signer material this handle is the + lowercase hex encoding of the serialized group verifying key as + produced by the tbtc-signer engine (the `KeyGroup` string), and + `ExtractDkgGroupPublicKeyFromMaterial` returns exactly these bytes. + The handle is treated as an opaque byte string; implementations + MUST NOT decode it to point bytes before hashing. +* `SessionID`: the session identifier's raw UTF-8 bytes. +* `MessageDigest`: the **raw signing message itself**, big-endian + left-padded with zeros to exactly 32 bytes; leading zero bytes are + insignificant, and more than 32 significant bytes MUST be rejected. + This is precisely keep-core's `messageDigestFromBigInt` output: in + BIP-340 production the signed message already is the 32-byte + sighash, so padding is a no-op. Implementations MUST NOT substitute + any transcript hash of the message here -- in particular not the + tbtc-signer engine's internal `SHA256(message_bytes)` digest, which + feeds only the engine's `round_id`/`attempt_id` derivations. Seeding + from the transcript digest instead of the padded message was a + cross-language coordinator divergence caught in review; the + engine-side contract is pinned by + `start_sign_round_accepts_go_derived_attempt_context_in_strict_mode`. +* `AttemptNumber`: the RFC-21 attempt number, **0-based** (attempt + zero is the first attempt). The tbtc-signer FFI `AttemptContext` + carries `wire_attempt_number = AttemptNumber + 1` (1-based, zero + rejected); implementations consuming the wire form MUST subtract + one before the composition below. +* `IncludedSet`: the attempt's included member indices. + +=== Derivation + +---- +AttemptSeed32 = SHA256(KeyGroupBytes || SessionID || MessageDigest) +ShuffleSeed_i64 = int64_from_be_bytes(AttemptSeed32[0..8]) +SourceSeed_i64 = ShuffleSeed_i64 + int64(AttemptNumber) // two's-complement wrap +Coordinator = GoMathRandShuffle(sort_ascending(IncludedSet), SourceSeed_i64)[0] +---- + +`GoMathRandShuffle` is the exact shuffle of Go's legacy `math/rand`: +`rand.New(rand.NewSource(seed)).Shuffle` -- ported bit-exactly to +Rust in `pkg/tbtc/signer/src/go_math_rand.rs` and pinned by the +cross-language vectors of keep-core PRs #4026/#4027. The addition is +two's-complement wrapping on both sides (Go's defined signed +overflow; Rust `wrapping_add`). + +=== Non-goals and accepted properties + +* The concatenation `KeyGroupBytes || SessionID || MessageDigest` is + not length-framed, so input-boundary collisions are theoretically + expressible. This is accepted: the seed feeds a *non-cryptographic* + shuffle whose only job is deterministic agreement on rotation + order among honest members; every input is independently and + unambiguously bound (and framed) in the `AttemptContext` hash that + protocol messages verify. The seed is not a security boundary, and + no exclusion or signing decision may ever be derived from it. +* Only the first 8 bytes of `AttemptSeed32` influence the shuffle; + the remaining 24 bytes are bound via the `AttemptContext` hash + only. +* Grindability: an adversary who can choose `SessionID` or + `MessageDigest` can bias coordinator rotation order. Session and + message identifiers are protocol-fixed inputs (deposit/redemption + driven), not free adversary choices; rotation-order bias degrades + at worst into the serial-attempt latency bound, never into a + safety property. + +=== History + +Before this annex, the two implementations diverged: the Go layer +derived `fold(SHA256(KeyGroup || SessionID || MessageDigest))` with +0-based attempts while the Rust engine used the first 8 bytes of the +raw `MessageDigest` with 1-based wire attempts (the legacy +`signingAttemptSeed` convention of the pre-ROAST signing loop, which +`pkg/tbtc/signing_loop.go` retains until Phase-7 migrates it to this +annex). The divergence was flagged in keep-core PR #4026 and resolved +by adopting the Go derivation as normative. + +== Annex B (informative): serial-attempt latency budget vs on-chain timeouts + +Paper-ROAST runs up to `n-t+1` concurrent signing sessions precisely so +byzantine participants cannot serialize delay; RFC-21 (and the legacy +retry loop it bridges from) runs attempts *serially*. This annex +computes what that costs against the bridge's on-chain deadlines, and +states plainly which constraint actually binds. + +=== Parameters (current code and chain values) + +[cols="3,1,3"] +|=== +| Parameter | Value | Source + +| Announcement delay + active + protocol + cool-down per attempt +| `1 + 5 + 30 + 5 = 41` blocks +| `pkg/tbtc/signing_loop.go` (`signingAttemptMaximumBlocks`) + +| Wall-clock per attempt (12 s blocks) +| ≈ 8.2 min +| Ethereum mainnet block time + +| `signingAttemptsLimit` +| 5 +| `pkg/tbtc/node.go` + +| Engine-side ROAST coordinator timeout +| 30 s default +| `TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS` (sub-dominant: the + block budget above dominates) + +| Signing group shape +| `n = 100`, `t = 51`, `f = n - t = 49` +| wallet parameters + +| `redemptionTimeout` +| 5 days (432,000 s) +| `tbtc-v2 Bridge.sol` (slashing-backed) + +| `movingFundsTimeout` / `movedFundsSweepTimeout` +| 7 days each +| `tbtc-v2 Bridge.sol` +|=== + +=== The serial-delay bound + +Each failed attempt costs at most one attempt budget (41 blocks +≈ 8.2 min). Three regimes: + +* *As deployed* (`signingAttemptsLimit = 5`): the loop spends at most + `5 × 41 = 205` blocks ≈ **41 minutes** before giving up — roughly + **175×** inside the 5-day redemption timeout. Serial latency is + nowhere near the binding constraint. +* *Review-suggested worst case* (`f` byzantine members each burning + one timeout, limit raised to `f + 1 = 50` attempts): `50 × 41 = + 2,050` blocks ≈ **6.8 hours** — still ~**17×** inside the redemption + timeout, and ~25× inside the moving-funds timeouts. +* *Byzantine coordinators only* (the regime bounded concurrency is + designed for): coordinator rotation visits at most `f` byzantine + coordinators before an honest one; with serial attempts that is the + same `f·τ ≈ 6.8 h` worst case as above. + +Conclusion of the arithmetic: serial attempts comfortably fit the +on-chain deadlines whenever attempts *can* succeed at all. The honest +caveat is the next section. + +=== What actually binds: subset sampling, not serial latency + +An attempt requires every included member to contribute (the +transitional finalize rejects partial contribution sets), and each +attempt's included set is drawn from ready members. The per-attempt +success probability against `f` byzantine-but-announcing members is +the probability of drawing an all-honest `t`-subset: + +[cols="1,2,2"] +|=== +| `f` | P(all-honest subset) per attempt | P(success within 5 attempts) + +| 1 | 0.490 | 0.966 +| 2 | 0.238 | 0.743 +| 3 | 0.114 | 0.454 +| 5 | 0.025 | 0.120 +|=== + +(`P = ∏_{i=0}^{f-1} (49-i)/(100-i)` for `n=100, t=51`; the +`signingAttemptsLimit = 5` rationale in `pkg/tbtc/node.go` assumes +`f ≤ 2`.) + +The table models the *deployed legacy loop*, which draws an +independent pseudo-random `t`-subset per attempt, so per-attempt +failures are approximately i.i.d. The RFC-21 Layer B transitional +path (every included member must contribute) has a different profile +in both directions: one-shot silence is absorbed in a single attempt +(the silent member is parked for the next), but the i.i.d. table is +*not* a worst case there — members that stagger silence across +attempts (alternating, so someone unparked is always silent), or that +submit their evidence snapshots while withholding signing +contributions (which bundle-absence parking does not detect; see +Layer B), can fail *every* attempt deterministically at small `f`. +Both regimes reach the same conclusion below. + +So for `f ≥ 3` the loop fails with better-than-even odds long before +any timeout is approached: **the binding liveness constraint is +sampling probability, not serial latency**. A patient adversary does +not even need coordinators — any included byzantine member can burn an +attempt by going silent after announcing, and silence parking is +strictly transient by design, so re-announcing members re-poison +later subsets. This is the liveness profile inherited from the +tECDSA-era loop; its production backstops are outside the signing +loop: operator-inactivity claims (`pkg/protocol/inactivity`), +redemption-timeout slashing, and wallet closure/moving-funds +retirement. + +=== The structural fix (Phase-7+ requirement, not an optimization) + +True ROAST semantics remove the per-attempt veto entirely: the +coordinator finalizes with the first `t` *responsive* members of the +included set (t-of-included finalize) and runs up to `n-t+1` bounded +concurrent sessions so a stalling coordinator costs nothing but its +own slot. Under those semantics a silent member costs zero attempts +(they are simply not among the first `t` responders), the sampling +table above becomes irrelevant (any `t` honest responders suffice, +which the `t`-of-`n` assumption guarantees exist), and the worst-case +added latency degenerates to the coordinator-rotation bound. The +design groundwork already exists in the signer repo +(`pkg/tbtc/signer/docs/true-late-t-of-n-finalize-considerations.md`). + +Recommendation codified by this annex: + +. Before the ECDSA-retirement phases, adopt t-of-included finalize for + the ROAST signing path (at minimum for redemption signings, which + carry slashing-backed deadlines). +. Treat bounded concurrent attempts (`n-t+1` cap) as the follow-on + once t-of-included finalize lands; with it, the `f·τ` serial bound + in this annex stops being the worst case and becomes the + no-concurrency fallback. +. Until then, the `signingAttemptsLimit = 5` / `f ≤ 2` assumption and + this annex's tables are the documented envelope; alerting should + fire when observed attempt failure rates imply `f ≥ 3` behaviour. + (Implemented: the `performance_signing_attempt_rolling_success_rate` + / `performance_signing_attempt_implied_f_alert` gauges fed by the + retry loop's attempt-outcome reporter; thresholds and operator + guidance in `docs/development/frost-roast-retry-rollout.adoc` and + `pkg/clientinfo/signing_liveness.go`.) + +== References + +* Ruffing, Ronge, Aranha, Schneider. ``ROAST: Robust Asynchronous + Schnorr Threshold Signatures.'' ACM CCS 2022. +* Komlo, Goldberg. ``FROST: Flexible Round-Optimized Schnorr Threshold + Signatures.'' SAC 2020. +* RFC-20: Schnorr/FROST Migration Scaffold (`docs/rfc/rfc-20-schnorr-frost-migration-scaffold.adoc`). +* Independent review of FROST/ROAST readiness branch: + https://github.com/threshold-network/keep-core/pull/3866. +* L5 paired error-code change: `tlabs-xyz/tbtc#425` (Rust producer) + + `threshold-network/keep-core#3961` (Go consumer). +* Receive-loop drop site (the generic two-round exchange's two + further sites were removed with the unreachable scaffolding in + commit `1c692bf03`; see Phase 6 status): +** `pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go` + (`collectBuildTaggedTBTCSignerRoundContributionMessages`) +* Byte-identical retry shuffle: +** `pkg/frost/retry/retry.go` +** `pkg/tecdsa/retry/retry.go` diff --git a/go.mod b/go.mod index 537bddae5e..9580a102c5 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pion/datachannel v1.5.10 // indirect diff --git a/pkg/bitcoin/electrum/electrum.go b/pkg/bitcoin/electrum/electrum.go index e670646e4a..2c896be287 100644 --- a/pkg/bitcoin/electrum/electrum.go +++ b/pkg/bitcoin/electrum/electrum.go @@ -434,6 +434,56 @@ func (c *Connection) GetTransactionsForPublicKeyHash( return transactions, nil } +// GetTransactionsForPublicKeyScripts gets confirmed transactions that pay to +// any of the given public key scripts. The returned transactions are ordered +// by block height in ascending order, i.e. the latest transaction is at the +// end of the list. +func (c *Connection) GetTransactionsForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, + limit int, +) ([]*bitcoin.Transaction, error) { + txHashes, err := c.GetTxHashesForPublicKeyScripts(publicKeyScripts) + if err != nil { + return nil, err + } + + selectedTxHashes := selectLatestUniqueTxHashes(txHashes, limit) + + transactions := make([]*bitcoin.Transaction, len(selectedTxHashes)) + for i, txHash := range selectedTxHashes { + transaction, err := c.GetTransaction(txHash) + if err != nil { + return nil, fmt.Errorf("cannot get transaction: [%v]", err) + } + + transactions[i] = transaction + } + + return transactions, nil +} + +func selectLatestUniqueTxHashes( + txHashes []bitcoin.Hash, + limit int, +) []bitcoin.Hash { + uniqueTxHashes := make([]bitcoin.Hash, 0, len(txHashes)) + seen := make(map[bitcoin.Hash]bool) + for _, txHash := range txHashes { + if seen[txHash] { + continue + } + + seen[txHash] = true + uniqueTxHashes = append(uniqueTxHashes, txHash) + } + + if len(uniqueTxHashes) > limit { + return uniqueTxHashes[len(uniqueTxHashes)-limit:] + } + + return uniqueTxHashes +} + // GetTxHashesForPublicKeyHash gets hashes of confirmed transactions that pays // the given public key hash using either a P2PKH or P2WPKH script. The returned // transactions hashes are ordered by block height in the ascending order, i.e. @@ -461,26 +511,19 @@ func (c *Connection) GetTxHashesForPublicKeyHash( ) } - p2pkhItems, err := c.getConfirmedScriptHistory(p2pkh) - if err != nil { - return nil, fmt.Errorf( - "cannot get P2PKH history for public key hash [0x%x]: [%v]", - publicKeyHash, - err, - ) - } + return c.GetTxHashesForPublicKeyScripts([]bitcoin.Script{p2pkh, p2wpkh}) +} - p2wpkhItems, err := c.getConfirmedScriptHistory(p2wpkh) +// GetTxHashesForPublicKeyScripts gets hashes of confirmed transactions that +// pay to any of the given public key scripts. +func (c *Connection) GetTxHashesForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, +) ([]bitcoin.Hash, error) { + items, err := c.getConfirmedScriptHistories(publicKeyScripts) if err != nil { - return nil, fmt.Errorf( - "cannot get P2WPKH history for public key hash [0x%x]: [%v]", - publicKeyHash, - err, - ) + return nil, err } - items := append(p2pkhItems, p2wpkhItems...) - sort.SliceStable( items, func(i, j int) bool { @@ -496,6 +539,27 @@ func (c *Connection) GetTxHashesForPublicKeyHash( return txHashes, nil } +func (c *Connection) getConfirmedScriptHistories( + publicKeyScripts []bitcoin.Script, +) ([]*scriptHistoryItem, error) { + items := make([]*scriptHistoryItem, 0) + + for _, publicKeyScript := range publicKeyScripts { + scriptItems, err := c.getConfirmedScriptHistory(publicKeyScript) + if err != nil { + return nil, fmt.Errorf( + "cannot get history for script [0x%x]: [%v]", + publicKeyScript, + err, + ) + } + + items = append(items, scriptItems...) + } + + return items, nil +} + type scriptHistoryItem struct { txHash bitcoin.Hash blockHeight int32 @@ -752,45 +816,15 @@ func (c *Connection) GetUtxosForPublicKeyHash( ) } - p2pkhItems, err := c.getScriptUtxos(p2pkh, true) - if err != nil { - return nil, fmt.Errorf( - "cannot get P2PKH UTXOs for public key hash [0x%x]: [%v]", - publicKeyHash, - err, - ) - } - - p2wpkhItems, err := c.getScriptUtxos(p2wpkh, true) - if err != nil { - return nil, fmt.Errorf( - "cannot get P2WPKH UTXOs for public key hash [0x%x]: [%v]", - publicKeyHash, - err, - ) - } - - items := append(p2pkhItems, p2wpkhItems...) - - sort.SliceStable( - items, - func(i, j int) bool { - return items[i].blockHeight < items[j].blockHeight - }, - ) - - utxos := make([]*bitcoin.UnspentTransactionOutput, len(items)) - for i, item := range items { - utxos[i] = &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: item.txHash, - OutputIndex: item.outputIndex, - }, - Value: int64(item.value), - } - } + return c.GetUtxosForPublicKeyScripts([]bitcoin.Script{p2pkh, p2wpkh}) +} - return utxos, nil +// GetUtxosForPublicKeyScripts gets unspent outputs of confirmed transactions +// that are controlled by any of the given public key scripts. +func (c *Connection) GetUtxosForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, +) ([]*bitcoin.UnspentTransactionOutput, error) { + return c.getUtxosForPublicKeyScripts(publicKeyScripts, true) } // GetMempoolUtxosForPublicKeyHash gets unspent outputs of unconfirmed transactions @@ -820,26 +854,35 @@ func (c *Connection) GetMempoolUtxosForPublicKeyHash( ) } - p2pkhItems, err := c.getScriptUtxos(p2pkh, false) + return c.GetMempoolUtxosForPublicKeyScripts([]bitcoin.Script{p2pkh, p2wpkh}) +} + +// GetMempoolUtxosForPublicKeyScripts gets unspent outputs of unconfirmed +// transactions that are controlled by any of the given public key scripts. +func (c *Connection) GetMempoolUtxosForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, +) ([]*bitcoin.UnspentTransactionOutput, error) { + return c.getUtxosForPublicKeyScripts(publicKeyScripts, false) +} + +func (c *Connection) getUtxosForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, + confirmed bool, +) ([]*bitcoin.UnspentTransactionOutput, error) { + items, err := c.getScriptUtxosForScripts(publicKeyScripts, confirmed) if err != nil { - return nil, fmt.Errorf( - "cannot get P2PKH UTXOs for public key hash [0x%x]: [%v]", - publicKeyHash, - err, - ) + return nil, err } - p2wpkhItems, err := c.getScriptUtxos(p2wpkh, false) - if err != nil { - return nil, fmt.Errorf( - "cannot get P2WPKH UTXOs for public key hash [0x%x]: [%v]", - publicKeyHash, - err, + if confirmed { + sort.SliceStable( + items, + func(i, j int) bool { + return items[i].blockHeight < items[j].blockHeight + }, ) } - items := append(p2pkhItems, p2wpkhItems...) - utxos := make([]*bitcoin.UnspentTransactionOutput, len(items)) for i, item := range items { utxos[i] = &bitcoin.UnspentTransactionOutput{ @@ -854,6 +897,28 @@ func (c *Connection) GetMempoolUtxosForPublicKeyHash( return utxos, nil } +func (c *Connection) getScriptUtxosForScripts( + publicKeyScripts []bitcoin.Script, + confirmed bool, +) ([]*scriptUtxoItem, error) { + items := make([]*scriptUtxoItem, 0) + + for _, publicKeyScript := range publicKeyScripts { + scriptItems, err := c.getScriptUtxos(publicKeyScript, confirmed) + if err != nil { + return nil, fmt.Errorf( + "cannot get UTXOs for script [0x%x]: [%v]", + publicKeyScript, + err, + ) + } + + items = append(items, scriptItems...) + } + + return items, nil +} + type scriptUtxoItem struct { txHash bitcoin.Hash outputIndex uint32 diff --git a/pkg/bitcoin/electrum/electrum_integration_test.go b/pkg/bitcoin/electrum/electrum_integration_test.go index d0f536c458..642340e83a 100644 --- a/pkg/bitcoin/electrum/electrum_integration_test.go +++ b/pkg/bitcoin/electrum/electrum_integration_test.go @@ -84,7 +84,7 @@ var testConfigs = map[string]testConfig{ }, "fulcrum tcp": { clientConfig: electrum.Config{ - URL: "tcp://v22019051929289916.bestsrv.de:50001", + URL: "tcp://blackie.c3-soft.com:57005", RequestTimeout: requestTimeout * 2, RequestRetryTimeout: requestRetryTimeout * 2, }, @@ -160,7 +160,7 @@ func init() { func TestConnect_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - _, cancelCtx := newTestConnection(t, testConfig.clientConfig) + _, cancelCtx := newRequiredTestConnection(t, testConfig.clientConfig) defer cancelCtx() }) } @@ -640,9 +640,32 @@ func runParallel(t *testing.T, runFunc func(t *testing.T, testConfig testConfig) } func newTestConnection(t *testing.T, config electrum.Config) (bitcoin.Chain, context.CancelFunc) { + t.Helper() + + return connectTestConnection(t, config, true) +} + +func newRequiredTestConnection(t *testing.T, config electrum.Config) (bitcoin.Chain, context.CancelFunc) { + t.Helper() + + return connectTestConnection(t, config, false) +} + +func connectTestConnection( + t *testing.T, + config electrum.Config, + skipTransientConnectionError bool, +) (bitcoin.Chain, context.CancelFunc) { + t.Helper() + ctx, cancelCtx := context.WithCancel(context.Background()) electrum, err := electrum.Connect(ctx, config) if err != nil { + cancelCtx() + if skipTransientConnectionError && shouldSkipElectrumIntegrationError(err) { + t.Skipf("skipping due to transient electrum connection error: %v", err) + } + t.Fatal(err) } diff --git a/pkg/bitcoin/script.go b/pkg/bitcoin/script.go index b6bdf144dc..405ea6ec08 100644 --- a/pkg/bitcoin/script.go +++ b/pkg/bitcoin/script.go @@ -19,6 +19,7 @@ const ( P2WPKHScript P2SHScript P2WSHScript + P2TRScript ) func (st ScriptType) String() string { @@ -31,6 +32,8 @@ func (st ScriptType) String() string { return "P2SH" case P2WSHScript: return "P2WSH" + case P2TRScript: + return "P2TR" default: return "NonStandard" } @@ -147,8 +150,25 @@ func PayToScriptHash(scriptHash [20]byte) (Script, error) { Script() } +// PayToTaproot constructs a P2TR script for the provided 32-byte x-only +// Taproot output key. The function assumes the provided output key is valid. +// +// The argument must be the final Taproot output key committed to by the +// scriptPubKey. This helper does not derive a BIP-341/BIP-86 tweak from an +// internal key. +func PayToTaproot(outputKey [32]byte) (Script, error) { + return txscript.NewScriptBuilder(). + AddOp(txscript.OP_1). + AddData(outputKey[:]). + Script() +} + // GetScriptType gets the ScriptType of the given Script. func GetScriptType(script Script) ScriptType { + if isPayToTaproot(script) { + return P2TRScript + } + switch txscript.GetScriptClass(script) { case txscript.PubKeyHashTy: return P2PKHScript @@ -163,6 +183,12 @@ func GetScriptType(script Script) ScriptType { } } +func isPayToTaproot(script Script) bool { + return len(script) == 34 && + script[0] == txscript.OP_1 && + script[1] == txscript.OP_DATA_32 +} + // ExtractPublicKeyHash extracts the public key hash from a P2WPKH or P2PKH // script. func ExtractPublicKeyHash(script Script) ([20]byte, error) { @@ -189,3 +215,15 @@ func ExtractPublicKeyHash(script Script) ([20]byte, error) { return publicKeyHash, nil } + +// ExtractTaprootKey extracts the x-only output key from a P2TR script. +func ExtractTaprootKey(script Script) ([32]byte, error) { + if GetScriptType(script) != P2TRScript { + return [32]byte{}, fmt.Errorf("not a P2TR script") + } + + var outputKey [32]byte + copy(outputKey[:], script[2:]) + + return outputKey, nil +} diff --git a/pkg/bitcoin/script_test.go b/pkg/bitcoin/script_test.go index 9de1d69869..6720b3b92b 100644 --- a/pkg/bitcoin/script_test.go +++ b/pkg/bitcoin/script_test.go @@ -9,6 +9,8 @@ import ( "testing" "github.com/btcsuite/btcd/btcec" + btcec2 "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/keep-network/keep-core/internal/testutils" ) @@ -334,6 +336,137 @@ func TestPayToScriptHash(t *testing.T) { testutils.AssertBytesEqual(t, expectedResult, result[:]) } +func TestPayToTaproot(t *testing.T) { + outputKeyBytes, err := hex.DecodeString( + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ) + if err != nil { + t.Fatal(err) + } + + var outputKey [32]byte + copy(outputKey[:], outputKeyBytes) + + result, err := PayToTaproot(outputKey) + if err != nil { + t.Fatal(err) + } + + expectedResult, err := hex.DecodeString( + "51201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertBytesEqual(t, expectedResult, result[:]) +} + +func TestTaprootLeafHash(t *testing.T) { + script := Script(hexToSlice( + t, + "76a9140102030405060708090a0b0c0d0e0f101112131488ac", + )) + + result, err := TaprootLeafHash(script) + if err != nil { + t.Fatal(err) + } + + expectedResult := hexToSlice( + t, + "37a57b86de2819d2b72a173df46238a7ad295ea1485d3b40e9415daa82b4fdcb", + ) + + testutils.AssertBytesEqual(t, expectedResult, result[:]) +} + +func TestTaprootTweakAndOutputKey(t *testing.T) { + privateKey, _ := btcec2.PrivKeyFromBytes( + hexToSlice( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ), + ) + + var internalKey [32]byte + copy(internalKey[:], schnorr.SerializePubKey(privateKey.PubKey())) + + refundLeaf := Script(hexToSlice( + t, + "76a9140102030405060708090a0b0c0d0e0f101112131488ac", + )) + + merkleRoot, err := TaprootLeafHash(refundLeaf) + if err != nil { + t.Fatal(err) + } + + tweak, err := TaprootTweak(internalKey, &merkleRoot) + if err != nil { + t.Fatal(err) + } + + expectedTweak := hexToSlice( + t, + "6ca66b4600554f36d490d227669ba78c2d4778a8ecc07565ae2f9e87c28f124a", + ) + + testutils.AssertBytesEqual(t, expectedTweak, tweak[:]) + + outputKey, err := TaprootOutputKey(internalKey, &merkleRoot) + if err != nil { + t.Fatal(err) + } + + expectedOutputKey := hexToSlice( + t, + "b31d6b4f10bcea1dfcace63ce7defda9e718a4340b4b5befef6194488780ef17", + ) + + testutils.AssertBytesEqual(t, expectedOutputKey, outputKey[:]) +} + +func TestPayToTaprootWithScriptTree(t *testing.T) { + privateKey, _ := btcec2.PrivKeyFromBytes( + hexToSlice( + t, + "0202020202020202020202020202020202020202020202020202020202020202", + ), + ) + + var internalKey [32]byte + copy(internalKey[:], schnorr.SerializePubKey(privateKey.PubKey())) + + merkleRootBytes := hexToSlice( + t, + "b2c459126150e0d47063ea7b6d0474a24c39e25908aae5740dd4787b67c6e19a", + ) + var merkleRoot [32]byte + copy(merkleRoot[:], merkleRootBytes) + + result, err := PayToTaprootWithScriptTree(internalKey, merkleRoot) + if err != nil { + t.Fatal(err) + } + + expectedOutputKey := hexToSlice( + t, + "e339710a2348c113ade4a4e7d52bd1c12bc69818f1af7f41e161142701b93c96", + ) + + // Rebuild the expected P2TR script directly to avoid reusing + // PayToTaprootWithScriptTree. + var expectedKey [32]byte + copy(expectedKey[:], expectedOutputKey) + expectedResult, err := PayToTaproot(expectedKey) + if err != nil { + t.Fatal(err) + } + + testutils.AssertBytesEqual(t, expectedResult, result) +} + func TestGetScriptType(t *testing.T) { fromHex := func(hexString string) []byte { bytes, err := hex.DecodeString(hexString) @@ -363,6 +496,10 @@ func TestGetScriptType(t *testing.T) { script: fromHex("002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca96"), expectedType: P2WSHScript, }, + "p2tr script": { + script: fromHex("51201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"), + expectedType: P2TRScript, + }, "non-standard script": { script: fromHex( "14934b98637ca318a4d6e7ca6ffd1690b8e77df6377508f9f0c90d0003" + @@ -387,6 +524,59 @@ func TestGetScriptType(t *testing.T) { } } +func TestExtractTaprootKey(t *testing.T) { + fromHex := func(hexString string) []byte { + bytes, err := hex.DecodeString(hexString) + if err != nil { + t.Fatal(err) + } + return bytes + } + + var outputKey [32]byte + copy( + outputKey[:], + fromHex("1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"), + ) + + var tests = map[string]struct { + script Script + expectedOutputKey [32]byte + expectedErr error + }{ + "P2TR script": { + script: fromHex("51201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"), + expectedOutputKey: outputKey, + }, + "other script": { + script: fromHex("00148db50eb52063ea9d98b3eac91489a90f738986f6"), + expectedErr: fmt.Errorf("not a P2TR script"), + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + actualOutputKey, err := ExtractTaprootKey(test.script) + + if !reflect.DeepEqual(test.expectedErr, err) { + t.Errorf( + "unexpected error\nexpected: %+v\nactual: %+v\n", + test.expectedErr, + err, + ) + } + + if test.expectedOutputKey != actualOutputKey { + t.Errorf( + "unexpected taproot output key\nexpected: 0x%x\nactual: 0x%x\n", + test.expectedOutputKey, + actualOutputKey, + ) + } + }) + } +} + func TestExtractPublicKeyHash(t *testing.T) { fromHex := func(hexString string) []byte { bytes, err := hex.DecodeString(hexString) diff --git a/pkg/bitcoin/taproot.go b/pkg/bitcoin/taproot.go new file mode 100644 index 0000000000..b3d44867f5 --- /dev/null +++ b/pkg/bitcoin/taproot.go @@ -0,0 +1,138 @@ +package bitcoin + +import ( + "bytes" + "fmt" + + btcec2 "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chaincfg/chainhash" +) + +const taprootBaseLeafVersion = 0xc0 + +// TaprootLeafHash computes the BIP-342 TapLeaf hash for a base-version script. +func TaprootLeafHash(script Script) ([32]byte, error) { + var buffer bytes.Buffer + + if err := buffer.WriteByte(taprootBaseLeafVersion); err != nil { + return [32]byte{}, err + } + + scriptLength, err := writeCompactSizeUint(CompactSizeUint(len(script))) + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot encode taproot script length: [%v]", + err, + ) + } + + if _, err := buffer.Write(scriptLength); err != nil { + return [32]byte{}, err + } + if _, err := buffer.Write(script); err != nil { + return [32]byte{}, err + } + + return taggedHashToArray( + chainhash.TaggedHash(chainhash.TagTapLeaf, buffer.Bytes()), + ), nil +} + +// TaprootTweak computes the BIP-341 TapTweak hash for an x-only internal key +// and optional script merkle root. +func TaprootTweak( + internalKey [32]byte, + merkleRoot *[32]byte, +) ([32]byte, error) { + _, _, tweak, err := taprootTweakScalar(internalKey, merkleRoot) + return tweak, err +} + +// TaprootOutputKey derives the BIP-341 tweaked x-only Taproot output key from +// an x-only internal key and optional script merkle root. +func TaprootOutputKey( + internalKey [32]byte, + merkleRoot *[32]byte, +) ([32]byte, error) { + internalPublicKey, tweakScalar, _, err := taprootTweakScalar( + internalKey, + merkleRoot, + ) + if err != nil { + return [32]byte{}, err + } + + var internalPoint btcec2.JacobianPoint + internalPublicKey.AsJacobian(&internalPoint) + + var tweakPoint btcec2.JacobianPoint + btcec2.ScalarBaseMultNonConst(&tweakScalar, &tweakPoint) + + var outputPoint btcec2.JacobianPoint + btcec2.AddNonConst(&internalPoint, &tweakPoint, &outputPoint) + + if outputPoint.Z.IsZero() { + return [32]byte{}, fmt.Errorf("taproot output key is infinity") + } + + outputPoint.ToAffine() + outputPublicKey := btcec2.NewPublicKey(&outputPoint.X, &outputPoint.Y) + + var outputKey [32]byte + copy(outputKey[:], schnorr.SerializePubKey(outputPublicKey)) + + return outputKey, nil +} + +// PayToTaprootWithScriptTree constructs a P2TR script from an internal key and +// a script merkle root by applying the BIP-341 TapTweak. +func PayToTaprootWithScriptTree( + internalKey [32]byte, + merkleRoot [32]byte, +) (Script, error) { + outputKey, err := TaprootOutputKey(internalKey, &merkleRoot) + if err != nil { + return nil, fmt.Errorf("cannot derive taproot output key: [%v]", err) + } + + return PayToTaproot(outputKey) +} + +func taprootTweakScalar( + internalKey [32]byte, + merkleRoot *[32]byte, +) (*btcec2.PublicKey, btcec2.ModNScalar, [32]byte, error) { + internalPublicKey, err := schnorr.ParsePubKey(internalKey[:]) + if err != nil { + return nil, btcec2.ModNScalar{}, [32]byte{}, fmt.Errorf( + "cannot parse taproot internal key: [%v]", + err, + ) + } + + tweakMessages := [][]byte{internalKey[:]} + if merkleRoot != nil { + tweakMessages = append(tweakMessages, merkleRoot[:]) + } + + tweak := taggedHashToArray( + chainhash.TaggedHash(chainhash.TagTapTweak, tweakMessages...), + ) + + var tweakScalar btcec2.ModNScalar + if overflow := tweakScalar.SetBytes(&tweak); overflow != 0 { + return nil, btcec2.ModNScalar{}, [32]byte{}, fmt.Errorf( + "taproot tweak is greater than or equal to curve order", + ) + } + + return internalPublicKey, tweakScalar, tweak, nil +} + +func taggedHashToArray(hash *chainhash.Hash) [32]byte { + var result [32]byte + copy(result[:], hash[:]) + + return result +} diff --git a/pkg/bitcoin/transaction_builder.go b/pkg/bitcoin/transaction_builder.go index 06e595d65d..3624ddc350 100644 --- a/pkg/bitcoin/transaction_builder.go +++ b/pkg/bitcoin/transaction_builder.go @@ -1,11 +1,16 @@ package bitcoin import ( + "bytes" "crypto/ecdsa" + "crypto/sha256" + "encoding/binary" + "encoding/hex" "fmt" "math/big" "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -32,8 +37,41 @@ func NewTransactionBuilder(chain Chain) *TransactionBuilder { } } +// HasTaprootKeyPathInputs returns true if the builder has at least one P2TR +// input intended to be spent using the Taproot key path. +func (tb *TransactionBuilder) HasTaprootKeyPathInputs() bool { + for _, sigHashArgs := range tb.sigHashArgs { + if sigHashArgs.scriptType == P2TRScript { + return true + } + } + + return false +} + +// HasOnlyTaprootKeyPathInputs returns true if every input in the builder is a +// P2TR input intended to be spent using the Taproot key path. +func (tb *TransactionBuilder) HasOnlyTaprootKeyPathInputs() bool { + if len(tb.sigHashArgs) == 0 { + return false + } + + for _, sigHashArgs := range tb.sigHashArgs { + if sigHashArgs.scriptType != P2TRScript { + return false + } + } + + return true +} + // AddPublicKeyHashInput adds an unsigned input pointing to a UTXO locked // using a P2PKH or P2WPKH script. +// +// For backward compatibility with wallet-action construction that discovers +// the input script type from the chain, this method also accepts P2TR direct +// key-path inputs. New Taproot-specific code should prefer +// AddTaprootKeyPathInput to make that spend policy explicit. func (tb *TransactionBuilder) AddPublicKeyHashInput( utxo *UnspentTransactionOutput, ) error { @@ -46,25 +84,133 @@ func (tb *TransactionBuilder) AddPublicKeyHashInput( ) } - class := txscript.GetScriptClass(utxoScript) - isPublicKeyHashScript := class == txscript.PubKeyHashTy || - class == txscript.WitnessV0PubKeyHashTy - if !isPublicKeyHashScript { + scriptType := GetScriptType(utxoScript) + isDirectKeySpendScript := scriptType == P2PKHScript || + scriptType == P2WPKHScript || + scriptType == P2TRScript + if !isDirectKeySpendScript { + return fmt.Errorf( + "UTXO pointed by the input is not P2PKH/P2WPKH/P2TR", + ) + } + + return tb.addDirectKeySpendInput(utxo, utxoScript, scriptType, nil) +} + +// AddTaprootKeyPathInput adds an unsigned input pointing to a UTXO locked +// using a P2TR script and intended to be spent using the Taproot key path. +// +// The script's x-only key is treated as the final Taproot output key. The +// builder does not apply a BIP-341/BIP-86 tap tweak during signing; callers +// must ensure the FROST signer can produce signatures for the exact output key +// committed to by the scriptPubKey. +func (tb *TransactionBuilder) AddTaprootKeyPathInput( + utxo *UnspentTransactionOutput, +) error { + utxoScript, err := tb.getScript(utxo) + if err != nil { + return fmt.Errorf( + "cannot get locking script for UTXO pointed "+ + "by the input: [%v]", + err, + ) + } + + scriptType := GetScriptType(utxoScript) + if scriptType != P2TRScript { return fmt.Errorf( - "UTXO pointed by the input is not P2PKH/P2WPKH", + "UTXO pointed by the input is not P2TR", ) } - // The UTXO was locked using a P2PKH/P2WPKH script so, the scriptCode - // required to build the sighash is equivalent to that script. Worth - // noting that the P2WPKH script is actually converted to the P2PKH script - // when used as a scriptCode, according to BIP-0143. For reference see, + return tb.addDirectKeySpendInput(utxo, utxoScript, scriptType, nil) +} + +// AddTaprootKeyPathInputWithMerkleRoot adds an unsigned input pointing to a +// UTXO locked using a BIP-341 tweaked P2TR output key and intended to be spent +// using the Taproot key path. +// +// The provided internal key and script merkle root must derive the output key +// committed to by the UTXO script. The merkle root is retained as signing +// metadata so the FROST signer can produce a key-path signature under the same +// Taproot tweak. +func (tb *TransactionBuilder) AddTaprootKeyPathInputWithMerkleRoot( + utxo *UnspentTransactionOutput, + internalKey [32]byte, + merkleRoot [32]byte, +) error { + utxoScript, err := tb.getScript(utxo) + if err != nil { + return fmt.Errorf( + "cannot get locking script for UTXO pointed "+ + "by the input: [%v]", + err, + ) + } + + scriptType := GetScriptType(utxoScript) + if scriptType != P2TRScript { + return fmt.Errorf( + "UTXO pointed by the input is not P2TR", + ) + } + + outputKey, err := ExtractTaprootKey(utxoScript) + if err != nil { + return fmt.Errorf("cannot extract taproot output key: [%v]", err) + } + + expectedOutputKey, err := TaprootOutputKey(internalKey, &merkleRoot) + if err != nil { + return fmt.Errorf("cannot derive taproot output key: [%v]", err) + } + + if !bytes.Equal(outputKey[:], expectedOutputKey[:]) { + return fmt.Errorf( + "taproot output key does not match internal key and merkle root", + ) + } + + return tb.addDirectKeySpendInput(utxo, utxoScript, scriptType, &merkleRoot) +} + +// TaprootKeyPathInputMerkleRoots returns per-input Taproot script merkle roots +// retained by the builder. The returned slice is aligned with transaction +// inputs. Non-Taproot inputs and untweaked Taproot inputs have nil entries. +func (tb *TransactionBuilder) TaprootKeyPathInputMerkleRoots() []*[32]byte { + merkleRoots := make([]*[32]byte, len(tb.sigHashArgs)) + + for i, sigHashArgs := range tb.sigHashArgs { + if sigHashArgs.taprootMerkleRoot == nil { + continue + } + + merkleRoots[i] = new([32]byte) + copy(merkleRoots[i][:], sigHashArgs.taprootMerkleRoot[:]) + } + + return merkleRoots +} + +func (tb *TransactionBuilder) addDirectKeySpendInput( + utxo *UnspentTransactionOutput, + utxoScript Script, + scriptType ScriptType, + taprootMerkleRoot *[32]byte, +) error { + // The UTXO was locked using a direct key-spend script, so the scriptCode + // required to build the sighash is equivalent to that script. Worth noting + // that the P2WPKH script is actually converted to the P2PKH script when + // used as a scriptCode, according to BIP-0143. For reference see, // https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki#specification. // That conversion is handled within the `txscript.CalcWitnessSigHash` call. sigHashArgs := &inputSigHashArgs{ - value: utxo.Value, - scriptCode: utxoScript, - witness: txscript.IsWitnessProgram(utxoScript), + value: utxo.Value, + publicKeyScript: utxoScript, + scriptCode: utxoScript, + scriptType: scriptType, + taprootMerkleRoot: taprootMerkleRoot, + witness: scriptType == P2WPKHScript || scriptType == P2TRScript, } hash := chainhash.Hash(utxo.Outpoint.TransactionHash) @@ -95,10 +241,10 @@ func (tb *TransactionBuilder) AddScriptHashInput( ) } - class := txscript.GetScriptClass(utxoScript) - isPublicKeyHashScript := class == txscript.ScriptHashTy || - class == txscript.WitnessV0ScriptHashTy - if !isPublicKeyHashScript { + scriptType := GetScriptType(utxoScript) + isScriptHashScript := scriptType == P2SHScript || + scriptType == P2WSHScript + if !isScriptHashScript { return fmt.Errorf( "UTXO pointed by the input is not P2SH/P2WSH", ) @@ -108,9 +254,11 @@ func (tb *TransactionBuilder) AddScriptHashInput( // to build the sighash is equivalent to the plain-text redeem script whose // hash is included in the P2SH/P2WSH script. sigHashArgs := &inputSigHashArgs{ - value: utxo.Value, - scriptCode: redeemScript, - witness: txscript.IsWitnessProgram(utxoScript), + value: utxo.Value, + publicKeyScript: utxoScript, + scriptCode: redeemScript, + scriptType: scriptType, + witness: scriptType == P2WSHScript, } hash := chainhash.Hash(utxo.Outpoint.TransactionHash) @@ -179,13 +327,34 @@ func (tb *TransactionBuilder) ComputeSignatureHashes() ([]*big.Int, error) { // sighash fragments can be pre-computed upfront and reused. witnessSigHashFragments := txscript.NewTxSigHashes(tb.internal.MsgTx) + var taprootSigHashMidstate *taprootSignatureHashMidstate + if tb.HasTaprootKeyPathInputs() { + var err error + taprootSigHashMidstate, err = tb.taprootSignatureHashMidstate( + tb.internal.MsgTx, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot calculate taproot sighash midstate: [%v]", + err, + ) + } + } + for i := range tb.internal.TxIn { sigHashArgs := tb.sigHashArgs[i] var sigHashBytes []byte var err error - if sigHashArgs.witness { + switch sigHashArgs.scriptType { + case P2TRScript: + sigHashBytes, err = tb.calcTaprootKeyPathSignatureHash( + tb.internal.MsgTx, + i, + taprootSigHashMidstate, + ) + case P2WPKHScript, P2WSHScript: sigHashBytes, err = txscript.CalcWitnessSigHash( sigHashArgs.scriptCode, witnessSigHashFragments, @@ -194,7 +363,7 @@ func (tb *TransactionBuilder) ComputeSignatureHashes() ([]*big.Int, error) { i, sigHashArgs.value, ) - } else { + default: sigHashBytes, err = txscript.CalcSignatureHash( sigHashArgs.scriptCode, txscript.SigHashAll, @@ -246,6 +415,14 @@ func (tb *TransactionBuilder) AddSignatures( for i, input := range tb.internal.TxIn { signature := signatures[i] + sigHashArgs := tb.sigHashArgs[i] + + if sigHashArgs.scriptType == P2TRScript { + return nil, fmt.Errorf( + "input [%v] is P2TR; use AddTaprootKeyPathSignatures", + i, + ) + } // Make a sanity check to avoid producing crap transactions. if !ecdsa.Verify( @@ -265,8 +442,6 @@ func (tb *TransactionBuilder) AddSignatures( signature.PublicKey, ).SerializeCompressed() - sigHashArgs := tb.sigHashArgs[i] - if sigHashArgs.witness { witness := wire.TxWitness{ signatureBytes, @@ -309,6 +484,81 @@ func (tb *TransactionBuilder) AddSignatures( return tb.internal.toTransaction(), nil } +// SchnorrSignatureContainer is a helper type holding a serialized 64-byte +// BIP-340 Schnorr signature. +type SchnorrSignatureContainer struct { + Signature [64]byte +} + +// AddTaprootKeyPathSignatures adds Schnorr signature data for P2TR key-path +// transaction inputs and returns a signed Transaction instance. +func (tb *TransactionBuilder) AddTaprootKeyPathSignatures( + signatures []*SchnorrSignatureContainer, +) (*Transaction, error) { + if len(tb.sigHashes) == 0 { + return nil, fmt.Errorf("signature hashes must be computed first") + } + + if len(signatures) != len(tb.internal.TxIn) { + return nil, fmt.Errorf("wrong signatures count") + } + + if !tb.HasOnlyTaprootKeyPathInputs() { + return nil, fmt.Errorf( + "taproot key-path signatures require all inputs to be P2TR", + ) + } + + for i, input := range tb.internal.TxIn { + signature := signatures[i] + if signature == nil { + return nil, fmt.Errorf("signature for input [%v] is nil", i) + } + + signatureBytes := make([]byte, len(signature.Signature)) + copy(signatureBytes, signature.Signature[:]) + + taprootKey, err := ExtractTaprootKey(tb.sigHashArgs[i].publicKeyScript) + if err != nil { + return nil, fmt.Errorf( + "cannot extract taproot key for input [%v]: [%v]", + i, + err, + ) + } + + taprootPublicKey, err := schnorr.ParsePubKey(taprootKey[:]) + if err != nil { + return nil, fmt.Errorf( + "cannot parse taproot key for input [%v]: [%v]", + i, + err, + ) + } + + taprootSignature, err := schnorr.ParseSignature(signatureBytes) + if err != nil { + return nil, fmt.Errorf( + "cannot parse taproot key-path signature for input [%v]: [%v]", + i, + err, + ) + } + + sigHashBytes := tb.sigHashes[i].FillBytes(make([]byte, sha256.Size)) + if !taprootSignature.Verify(sigHashBytes, taprootPublicKey) { + return nil, fmt.Errorf( + "invalid taproot key-path signature for input [%v]", + i, + ) + } + + input.Witness = wire.TxWitness{signatureBytes} + } + + return tb.internal.toTransaction(), nil +} + // TotalInputsValue returns the total value of transaction inputs. func (tb *TransactionBuilder) TotalInputsValue() int64 { totalInputsValue := int64(0) @@ -320,15 +570,408 @@ func (tb *TransactionBuilder) TotalInputsValue() int64 { return totalInputsValue } +// ReplaceUnsignedTransaction replaces the internal unsigned transaction while +// preserving per-input sighash metadata collected during builder input setup. +func (tb *TransactionBuilder) ReplaceUnsignedTransaction( + transaction *Transaction, +) error { + if transaction == nil { + return fmt.Errorf("transaction is nil") + } + + if len(transaction.Inputs) != len(tb.sigHashArgs) { + return fmt.Errorf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + len(transaction.Inputs), + len(tb.sigHashArgs), + ) + } + + previousInputs := tb.internal.TxIn + + replacedInternal := newInternalTransaction() + replacedInternal.fromTransaction(transaction) + + for i := range replacedInternal.TxIn { + previousInput := previousInputs[i] + replacedInput := replacedInternal.TxIn[i] + + if previousInput == nil || replacedInput == nil { + continue + } + + if len(replacedInput.SignatureScript) > 0 { + return fmt.Errorf( + "replacement transaction input [%d] has unexpected non-empty signature script", + i, + ) + } + + if len(replacedInput.Witness) > 0 { + return fmt.Errorf( + "replacement transaction input [%d] has unexpected non-empty witness", + i, + ) + } + + // The replacement's SignatureScript and Witness are both empty here + // because of the two refusals above, so the per-input restore below + // only has to decide what to copy *from* the previous input. + if tb.sigHashArgs[i].witness { + // Witness inputs may carry a single-element pre-signing witness + // that holds a P2WSH-style redeem script. Multi-element witnesses + // belong to P2TR script-path spends or other workflows that are + // not in scope for the current FROST migration, and silently + // dropping them produced malformed transactions later — refuse + // instead so the unsupported case fails loudly. Lifting this to + // support multi-element witnesses requires a per-input policy + // rather than a blanket copy because the replacement could + // legitimately differ in witness shape from the previous input. + switch len(previousInput.Witness) { + case 0: + // Nothing to restore (typical P2TR key-path or P2WPKH). + case 1: + redeemScript := append([]byte{}, previousInput.Witness[0]...) + replacedInput.Witness = wire.TxWitness{redeemScript} + default: + return fmt.Errorf( + "replacement transaction input [%d] previous witness has "+ + "[%d] elements; only zero- or single-element "+ + "pre-signing witnesses are currently supported for "+ + "restoration", + i, + len(previousInput.Witness), + ) + } + } else if len(previousInput.SignatureScript) > 0 { + replacedInput.SignatureScript = append( + []byte{}, + previousInput.SignatureScript..., + ) + } + } + + tb.internal = replacedInternal + tb.sigHashes = nil + + return nil +} + +// UnsignedTransaction returns the current unsigned transaction builder state. +func (tb *TransactionBuilder) UnsignedTransaction() *Transaction { + return tb.internal.toTransaction() +} + +// UnsignedTransactionInput carries canonical unsigned input metadata extracted +// from the builder state. +type UnsignedTransactionInput struct { + TxIDHex string + Vout uint32 + ValueSats uint64 +} + +// UnsignedTransactionOutput carries canonical unsigned output metadata +// extracted from the builder state. +type UnsignedTransactionOutput struct { + ScriptPubKeyHex string + ValueSats uint64 +} + +// UnsignedTransactionIO returns canonical unsigned transaction input/output +// metadata from the builder state. +func (tb *TransactionBuilder) UnsignedTransactionIO() ( + []UnsignedTransactionInput, + []UnsignedTransactionOutput, + error, +) { + if len(tb.internal.TxIn) != len(tb.sigHashArgs) { + return nil, nil, fmt.Errorf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + len(tb.internal.TxIn), + len(tb.sigHashArgs), + ) + } + + inputs := make([]UnsignedTransactionInput, 0, len(tb.internal.TxIn)) + for i, input := range tb.internal.TxIn { + value := tb.sigHashArgs[i].value + if value < 0 { + return nil, nil, fmt.Errorf("input [%d] value is negative", i) + } + + inputs = append( + inputs, + UnsignedTransactionInput{ + // chainhash.Hash.String renders txid in standard Bitcoin display + // (RPC/explorer) byte order, i.e. reversed vs internal bytes. + TxIDHex: input.PreviousOutPoint.Hash.String(), + Vout: input.PreviousOutPoint.Index, + ValueSats: uint64(value), + }, + ) + } + + outputs := make([]UnsignedTransactionOutput, 0, len(tb.internal.TxOut)) + for i, output := range tb.internal.TxOut { + if output.Value < 0 { + return nil, nil, fmt.Errorf("output [%d] value is negative", i) + } + + outputs = append( + outputs, + UnsignedTransactionOutput{ + ScriptPubKeyHex: hex.EncodeToString(output.PkScript), + ValueSats: uint64(output.Value), + }, + ) + } + + return inputs, outputs, nil +} + +func (tb *TransactionBuilder) calcTaprootKeyPathSignatureHash( + tx *wire.MsgTx, + inputIndex int, + midstate *taprootSignatureHashMidstate, +) ([]byte, error) { + if tx == nil { + return nil, fmt.Errorf("transaction is nil") + } + if midstate == nil { + return nil, fmt.Errorf("taproot sighash midstate is nil") + } + + if inputIndex < 0 || inputIndex >= len(tx.TxIn) { + return nil, fmt.Errorf( + "input index [%d] out of range for [%d] inputs", + inputIndex, + len(tx.TxIn), + ) + } + + if len(tx.TxIn) != len(tb.sigHashArgs) { + return nil, fmt.Errorf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + len(tx.TxIn), + len(tb.sigHashArgs), + ) + } + + var sigMsg bytes.Buffer + + // BIP-341 defines the final digest as tagged_hash("TapSighash", + // 0x00 || SigMsg(0x00, 0)). The first byte is the epoch and the second + // byte is SIGHASH_DEFAULT. + sigMsg.WriteByte(0x00) + sigMsg.WriteByte(0x00) + + if err := binary.Write(&sigMsg, binary.LittleEndian, tx.Version); err != nil { + return nil, err + } + if err := binary.Write(&sigMsg, binary.LittleEndian, tx.LockTime); err != nil { + return nil, err + } + + sigMsg.Write(midstate.hashPrevOuts[:]) + sigMsg.Write(midstate.hashInputAmounts[:]) + sigMsg.Write(midstate.hashInputScripts[:]) + sigMsg.Write(midstate.hashSequences[:]) + sigMsg.Write(midstate.hashOutputs[:]) + + // Key-path spends use ext_flag=0 and this implementation does not attach + // a Taproot annex, so spend_type is 0. + sigMsg.WriteByte(0x00) + + if err := binary.Write( + &sigMsg, + binary.LittleEndian, + uint32(inputIndex), + ); err != nil { + return nil, err + } + + hash := chainhash.TaggedHash([]byte("TapSighash"), sigMsg.Bytes()) + return hash.CloneBytes(), nil +} + +type taprootSignatureHashMidstate struct { + hashPrevOuts [chainhash.HashSize]byte + hashInputAmounts [chainhash.HashSize]byte + hashInputScripts [chainhash.HashSize]byte + hashSequences [chainhash.HashSize]byte + hashOutputs [chainhash.HashSize]byte +} + +func (tb *TransactionBuilder) taprootSignatureHashMidstate( + tx *wire.MsgTx, +) (*taprootSignatureHashMidstate, error) { + if tx == nil { + return nil, fmt.Errorf("transaction is nil") + } + + if len(tx.TxIn) != len(tb.sigHashArgs) { + return nil, fmt.Errorf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + len(tx.TxIn), + len(tb.sigHashArgs), + ) + } + + hashPrevOuts, err := tb.taprootHashPrevOuts(tx) + if err != nil { + return nil, err + } + + hashInputAmounts, err := tb.taprootHashInputAmounts() + if err != nil { + return nil, err + } + + hashInputScripts, err := tb.taprootHashInputScripts() + if err != nil { + return nil, err + } + + hashSequences, err := tb.taprootHashSequences(tx) + if err != nil { + return nil, err + } + + hashOutputs, err := tb.taprootHashOutputs(tx) + if err != nil { + return nil, err + } + + return &taprootSignatureHashMidstate{ + hashPrevOuts: hashPrevOuts, + hashInputAmounts: hashInputAmounts, + hashInputScripts: hashInputScripts, + hashSequences: hashSequences, + hashOutputs: hashOutputs, + }, nil +} + +func (tb *TransactionBuilder) taprootHashPrevOuts( + tx *wire.MsgTx, +) ([chainhash.HashSize]byte, error) { + var buffer bytes.Buffer + for _, input := range tx.TxIn { + if err := writeOutPoint(&buffer, &input.PreviousOutPoint); err != nil { + return [chainhash.HashSize]byte{}, err + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func (tb *TransactionBuilder) taprootHashInputAmounts() ( + [chainhash.HashSize]byte, + error, +) { + var buffer bytes.Buffer + for i, sigHashArgs := range tb.sigHashArgs { + if sigHashArgs.value < 0 { + return [chainhash.HashSize]byte{}, fmt.Errorf( + "input [%d] value is negative", + i, + ) + } + + if err := binary.Write( + &buffer, + binary.LittleEndian, + uint64(sigHashArgs.value), + ); err != nil { + return [chainhash.HashSize]byte{}, err + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func (tb *TransactionBuilder) taprootHashInputScripts() ( + [chainhash.HashSize]byte, + error, +) { + var buffer bytes.Buffer + for i, sigHashArgs := range tb.sigHashArgs { + if err := wire.WriteVarBytes( + &buffer, + 0, + sigHashArgs.publicKeyScript, + ); err != nil { + return [chainhash.HashSize]byte{}, fmt.Errorf( + "cannot write public key script for input [%d]: [%v]", + i, + err, + ) + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func (tb *TransactionBuilder) taprootHashSequences( + tx *wire.MsgTx, +) ([chainhash.HashSize]byte, error) { + var buffer bytes.Buffer + for _, input := range tx.TxIn { + if err := binary.Write( + &buffer, + binary.LittleEndian, + input.Sequence, + ); err != nil { + return [chainhash.HashSize]byte{}, err + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func (tb *TransactionBuilder) taprootHashOutputs( + tx *wire.MsgTx, +) ([chainhash.HashSize]byte, error) { + var buffer bytes.Buffer + for i, output := range tx.TxOut { + if err := wire.WriteTxOut(&buffer, 0, 0, output); err != nil { + return [chainhash.HashSize]byte{}, fmt.Errorf( + "cannot write output [%d]: [%v]", + i, + err, + ) + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func writeOutPoint(buffer *bytes.Buffer, outpoint *wire.OutPoint) error { + if _, err := buffer.Write(outpoint.Hash[:]); err != nil { + return err + } + + return binary.Write(buffer, binary.LittleEndian, outpoint.Index) +} + // inputSigHashArgs is a helper structure holding some arguments required to // compute a sighash for the given input. type inputSigHashArgs struct { // value denotes the satoshi value of the UTXO pointed by the given input. value int64 + // publicKeyScript is the locking script of the UTXO pointed by the given + // input. + publicKeyScript []byte // scriptCode is a component of the input's sighash and is the script that // is actually executed while unlocking the given UTXO. The scriptCode // depends on the script type that was used to lock the given UTXO. scriptCode []byte + // scriptType denotes the locking script type of the UTXO pointed by the + // given input. + scriptType ScriptType + // taprootMerkleRoot denotes the BIP-341 script merkle root used to tweak + // the P2TR input's output key. It is nil for untweaked P2TR inputs and + // non-Taproot inputs. + taprootMerkleRoot *[32]byte // witness denotes whether the given input point's to a UTXO locked using // a witness script. witness bool diff --git a/pkg/bitcoin/transaction_builder_test.go b/pkg/bitcoin/transaction_builder_test.go index 96adf8dede..74335991ef 100644 --- a/pkg/bitcoin/transaction_builder_test.go +++ b/pkg/bitcoin/transaction_builder_test.go @@ -7,6 +7,10 @@ import ( "strings" "testing" + btcec2 "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/keep-network/keep-core/internal/testutils" ) @@ -111,6 +115,267 @@ func TestTransactionBuilder_AddPublicKeyHashInput(t *testing.T) { } } +func TestTransactionBuilder_AddPublicKeyHashInput_AcceptsTaprootKeyPathInputForBackwardCompatibility( + t *testing.T, +) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + var taprootOutputKey [32]byte + copy( + taprootOutputKey[:], + hexToSlice( + t, + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ), + ) + + lockingScript, err := PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatal(err) + } + + inputTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{0x01}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 100000, + PublicKeyScript: lockingScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(inputTransaction); err != nil { + t.Fatal(err) + } + + inputTransactionUtxo := &UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: inputTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + if err := builder.AddPublicKeyHashInput(inputTransactionUtxo); err != nil { + t.Fatal(err) + } + + if !builder.HasTaprootKeyPathInputs() { + t.Fatal("expected builder to have taproot key-path inputs") + } + if !builder.HasOnlyTaprootKeyPathInputs() { + t.Fatal("expected builder to have only taproot key-path inputs") + } + + assertSigHashArgs( + t, + &inputSigHashArgs{ + value: inputTransactionUtxo.Value, + publicKeyScript: lockingScript, + scriptCode: lockingScript, + scriptType: P2TRScript, + witness: true, + }, + builder.sigHashArgs[0], + ) + assertInternalInput(t, builder, 0, &TransactionInput{ + Outpoint: inputTransactionUtxo.Outpoint, + SignatureScript: nil, + Witness: nil, + Sequence: 0xffffffff, + }) +} + +func TestTransactionBuilder_AddTaprootKeyPathInputWithMerkleRoot(t *testing.T) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + privateKey, _ := btcec2.PrivKeyFromBytes( + hexToSlice( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ), + ) + + var internalKey [32]byte + copy(internalKey[:], schnorr.SerializePubKey(privateKey.PubKey())) + + refundLeaf := Script(hexToSlice( + t, + "76a9140102030405060708090a0b0c0d0e0f101112131488ac", + )) + merkleRoot, err := TaprootLeafHash(refundLeaf) + if err != nil { + t.Fatal(err) + } + + outputKey, err := TaprootOutputKey(internalKey, &merkleRoot) + if err != nil { + t.Fatal(err) + } + + lockingScript, err := PayToTaproot(outputKey) + if err != nil { + t.Fatal(err) + } + + inputTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{0x01}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 100000, + PublicKeyScript: lockingScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(inputTransaction); err != nil { + t.Fatal(err) + } + + inputTransactionUtxo := &UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: inputTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + if err := builder.AddTaprootKeyPathInputWithMerkleRoot( + inputTransactionUtxo, + internalKey, + merkleRoot, + ); err != nil { + t.Fatal(err) + } + + assertSigHashArgs( + t, + &inputSigHashArgs{ + value: inputTransactionUtxo.Value, + publicKeyScript: lockingScript, + scriptCode: lockingScript, + scriptType: P2TRScript, + taprootMerkleRoot: &merkleRoot, + witness: true, + }, + builder.sigHashArgs[0], + ) + + merkleRoots := builder.TaprootKeyPathInputMerkleRoots() + if len(merkleRoots) != 1 { + t.Fatalf("unexpected merkle roots count: [%v]", len(merkleRoots)) + } + testutils.AssertBytesEqual(t, merkleRoot[:], merkleRoots[0][:]) +} + +func TestTransactionBuilder_AddTaprootKeyPathInputWithMerkleRootRejectsMismatch( + t *testing.T, +) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + privateKey, _ := btcec2.PrivKeyFromBytes( + hexToSlice( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ), + ) + + var internalKey [32]byte + copy(internalKey[:], schnorr.SerializePubKey(privateKey.PubKey())) + + merkleRoot, err := TaprootLeafHash(Script(hexToSlice( + t, + "76a9140102030405060708090a0b0c0d0e0f101112131488ac", + ))) + if err != nil { + t.Fatal(err) + } + + wrongMerkleRoot, err := TaprootLeafHash(Script(hexToSlice( + t, + "76a914ffffffffffffffffffffffffffffffffffffffff88ac", + ))) + if err != nil { + t.Fatal(err) + } + + outputKey, err := TaprootOutputKey(internalKey, &merkleRoot) + if err != nil { + t.Fatal(err) + } + + lockingScript, err := PayToTaproot(outputKey) + if err != nil { + t.Fatal(err) + } + + inputTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{0x01}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 100000, + PublicKeyScript: lockingScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(inputTransaction); err != nil { + t.Fatal(err) + } + + err = builder.AddTaprootKeyPathInputWithMerkleRoot( + &UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: inputTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + internalKey, + wrongMerkleRoot, + ) + if err == nil { + t.Fatal("expected taproot output key mismatch error") + } + if !strings.Contains(err.Error(), "taproot output key does not match") { + t.Fatalf("unexpected error: [%v]", err) + } +} + func TestTransactionBuilder_AddInputReturnsErrorForOutOfRangeOutputIndex( t *testing.T, ) { @@ -245,6 +510,744 @@ func TestTransactionBuilder_AddOutput(t *testing.T) { assertInternalOutput(t, builder, 0, output) } +func TestTransactionBuilder_AddTaprootKeyPathSignatures(t *testing.T) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + privateKeyBytes := hexToSlice( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ) + privateKey, publicKey := btcec2.PrivKeyFromBytes(privateKeyBytes) + + var taprootOutputKey [32]byte + copy(taprootOutputKey[:], schnorr.SerializePubKey(publicKey)) + + inputScript, err := PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatal(err) + } + + var outputPublicKeyHash [20]byte + copy( + outputPublicKeyHash[:], + hexToSlice(t, "0202020202020202020202020202020202020202"), + ) + outputScript, err := PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + previousTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{ + 0x10, 0x11, 0x12, 0x13, + 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, + 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x22, 0x23, + 0x24, 0x25, 0x26, 0x27, + 0x28, 0x29, 0x2a, 0x2b, + 0x2c, 0x2d, 0x2e, 0x2f, + }, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 100000, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(previousTransaction); err != nil { + t.Fatal(err) + } + + err = builder.AddTaprootKeyPathInput(&UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: previousTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }) + if err != nil { + t.Fatal(err) + } + + builder.AddOutput(&TransactionOutput{ + Value: 90000, + PublicKeyScript: outputScript, + }) + + if !builder.HasTaprootKeyPathInputs() { + t.Fatal("expected builder to have taproot key-path inputs") + } + if !builder.HasOnlyTaprootKeyPathInputs() { + t.Fatal("expected builder to have only taproot key-path inputs") + } + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual(t, "signature hashes count", 1, len(sigHashes)) + + expectedSigHash := hexToSlice( + t, + "96653d19d603d309d22cfe2ccd0ba445e40629dea18d46108caa601055ec4318", + ) + // This vector was generated with btcd v0.23.4's BIP-341 + // CalcTaprootSignatureHash implementation and independently + // cross-checked by reviewers. + sigHashBytes := sigHashes[0].FillBytes(make([]byte, 32)) + testutils.AssertBytesEqual(t, expectedSigHash, sigHashBytes) + + signature, err := schnorr.Sign(privateKey, sigHashBytes) + if err != nil { + t.Fatal(err) + } + signatureBytes := signature.Serialize() + + expectedSignature := hexToSlice( + t, + "5e847a0c22486f3b89ff80edd5afaf4be550aa411a0a7e28cff19d2b5924d77102bbf9a0a51100f4fdfc8435d0e8ff0f61dfdeccd464b78c553b1b4414ac0877", + ) + testutils.AssertBytesEqual(t, expectedSignature, signatureBytes) + + var signatureContainer [64]byte + copy(signatureContainer[:], signatureBytes) + + transaction, err := builder.AddTaprootKeyPathSignatures( + []*SchnorrSignatureContainer{ + { + Signature: signatureContainer, + }, + }, + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual( + t, + "transaction inputs count", + 1, + len(transaction.Inputs), + ) + testutils.AssertIntsEqual( + t, + "taproot witness elements count", + 1, + len(transaction.Inputs[0].Witness), + ) + testutils.AssertBytesEqual( + t, + expectedSignature, + transaction.Inputs[0].Witness[0], + ) + testutils.AssertBytesEqual(t, nil, transaction.Inputs[0].SignatureScript) +} + +func TestTransactionBuilder_AddTaprootKeyPathSignatures_MultipleInputs( + t *testing.T, +) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + privateKeyBytes := hexToSlice( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ) + privateKey, publicKey := btcec2.PrivKeyFromBytes(privateKeyBytes) + + var taprootOutputKey [32]byte + copy(taprootOutputKey[:], schnorr.SerializePubKey(publicKey)) + + inputScript, err := PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatal(err) + } + + var outputPublicKeyHash [20]byte + copy( + outputPublicKeyHash[:], + hexToSlice(t, "0202020202020202020202020202020202020202"), + ) + outputScript, err := PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + inputValues := []int64{100000, 110000} + for i, value := range inputValues { + previousTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{byte(0x20 + i)}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: value, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(previousTransaction); err != nil { + t.Fatal(err) + } + + err = builder.AddTaprootKeyPathInput(&UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: previousTransaction.Hash(), + OutputIndex: 0, + }, + Value: value, + }) + if err != nil { + t.Fatal(err) + } + } + + builder.AddOutput(&TransactionOutput{ + Value: 209000, + PublicKeyScript: outputScript, + }) + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual(t, "signature hashes count", 2, len(sigHashes)) + if sigHashes[0].Cmp(sigHashes[1]) == 0 { + t.Fatal("expected distinct taproot signature hashes") + } + + signatures := make([]*SchnorrSignatureContainer, len(sigHashes)) + expectedSignatures := make([][]byte, len(sigHashes)) + for i, sigHash := range sigHashes { + signature, err := schnorr.Sign( + privateKey, + sigHash.FillBytes(make([]byte, 32)), + ) + if err != nil { + t.Fatal(err) + } + + signatureBytes := signature.Serialize() + expectedSignatures[i] = signatureBytes + + var signatureContainer [64]byte + copy(signatureContainer[:], signatureBytes) + signatures[i] = &SchnorrSignatureContainer{ + Signature: signatureContainer, + } + } + + transaction, err := builder.AddTaprootKeyPathSignatures(signatures) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual( + t, + "transaction inputs count", + 2, + len(transaction.Inputs), + ) + for i, input := range transaction.Inputs { + testutils.AssertIntsEqual( + t, + fmt.Sprintf("taproot witness elements count for input [%d]", i), + 1, + len(input.Witness), + ) + testutils.AssertBytesEqual(t, expectedSignatures[i], input.Witness[0]) + testutils.AssertBytesEqual(t, nil, input.SignatureScript) + } +} + +func TestTransactionBuilder_AddSignaturesRejectsTaprootInput(t *testing.T) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + var taprootOutputKey [32]byte + copy( + taprootOutputKey[:], + hexToSlice( + t, + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ), + ) + inputScript, err := PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatal(err) + } + + previousTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{0x01}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 100000, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(previousTransaction); err != nil { + t.Fatal(err) + } + + err = builder.AddTaprootKeyPathInput(&UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: previousTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }) + if err != nil { + t.Fatal(err) + } + + var outputPublicKeyHash [20]byte + outputScript, err := PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatal(err) + } + builder.AddOutput(&TransactionOutput{ + Value: 90000, + PublicKeyScript: outputScript, + }) + + if _, err := builder.ComputeSignatureHashes(); err != nil { + t.Fatal(err) + } + + _, err = builder.AddSignatures([]*SignatureContainer{ + { + R: big.NewInt(1), + S: big.NewInt(1), + }, + }) + if err == nil { + t.Fatal("expected AddSignatures to reject a taproot input") + } + if !strings.Contains(err.Error(), "use AddTaprootKeyPathSignatures") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestTransactionBuilder_ReplaceUnsignedTransaction(t *testing.T) { + builder := NewTransactionBuilder(nil) + + var initialInputHash1 chainhash.Hash + var initialInputHash2 chainhash.Hash + initialInputHash1[0] = 0x11 + initialInputHash2[0] = 0x22 + + builder.internal.AddTxIn( + wire.NewTxIn( + wire.NewOutPoint(&initialInputHash1, 1), + []byte{0xde, 0xad}, + nil, + ), + ) + builder.internal.AddTxIn( + wire.NewTxIn( + wire.NewOutPoint(&initialInputHash2, 2), + nil, + [][]byte{{0xbe, 0xef}}, + ), + ) + builder.sigHashArgs = append( + builder.sigHashArgs, + &inputSigHashArgs{value: 111, scriptCode: []byte{0x51}, witness: false}, + &inputSigHashArgs{value: 222, scriptCode: []byte{0x52}, witness: true}, + ) + builder.sigHashes = []*big.Int{big.NewInt(1), big.NewInt(2)} + + var replacementInputHash1 chainhash.Hash + var replacementInputHash2 chainhash.Hash + replacementInputHash1[0] = 0x33 + replacementInputHash2[0] = 0x44 + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Version: 2, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(replacementInputHash1), + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(replacementInputHash2), + OutputIndex: 8, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 1000, + PublicKeyScript: hexToSlice(t, "0014deadbeef"), + }, + }, + Locktime: 0, + }, + ) + if err != nil { + t.Fatalf("unexpected replacement error: [%v]", err) + } + + if len(builder.sigHashes) != 0 { + t.Fatalf("expected sighashes reset after replacement: [%d]", len(builder.sigHashes)) + } + + // Preserve P2SH/P2WSH placeholder scripts needed for final signature + // application while replacing tx skeleton. + if !reflect.DeepEqual([]byte{0xde, 0xad}, builder.internal.TxIn[0].SignatureScript) { + t.Fatalf( + "unexpected preserved signature script\nexpected: [%x]\nactual: [%x]", + []byte{0xde, 0xad}, + builder.internal.TxIn[0].SignatureScript, + ) + } + + if len(builder.internal.TxIn[1].Witness) != 1 { + t.Fatalf("unexpected preserved witness length: [%d]", len(builder.internal.TxIn[1].Witness)) + } + + if !reflect.DeepEqual([]byte{0xbe, 0xef}, builder.internal.TxIn[1].Witness[0]) { + t.Fatalf( + "unexpected preserved witness script\nexpected: [%x]\nactual: [%x]", + []byte{0xbe, 0xef}, + builder.internal.TxIn[1].Witness[0], + ) + } + + inputs, outputs, err := builder.UnsignedTransactionIO() + if err != nil { + t.Fatalf("unexpected extraction error after replacement: [%v]", err) + } + + if len(inputs) != 2 { + t.Fatalf("unexpected input count after replacement: [%d]", len(inputs)) + } + + if inputs[0].TxIDHex != replacementInputHash1.String() || inputs[0].Vout != 7 { + t.Fatalf("unexpected first input after replacement: [%+v]", inputs[0]) + } + + if inputs[1].TxIDHex != replacementInputHash2.String() || inputs[1].Vout != 8 { + t.Fatalf("unexpected second input after replacement: [%+v]", inputs[1]) + } + + if len(outputs) != 1 { + t.Fatalf("unexpected output count after replacement: [%d]", len(outputs)) + } +} + +func TestTransactionBuilder_ReplaceUnsignedTransaction_RejectsInputMetadataMismatch( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append(builder.sigHashArgs, &inputSigHashArgs{value: 1}) + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 0, + }, + }, + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 1, + }, + }, + }, + }, + ) + if err == nil { + t.Fatal("expected input metadata mismatch error") + } + + if !reflect.DeepEqual( + fmt.Sprintf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + 2, + 1, + ), + err.Error(), + ) { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestTransactionBuilder_ReplaceUnsignedTransaction_RejectsNonEmptyReplacementSignatureScript( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append( + builder.sigHashArgs, + &inputSigHashArgs{value: 1, scriptCode: []byte{0x51}, witness: false}, + ) + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 0, + }, + SignatureScript: []byte{0xaa}, + Sequence: 0xffffffff, + }, + }, + }, + ) + if err == nil { + t.Fatal("expected replacement signature script error") + } + + if !strings.Contains( + err.Error(), + "replacement transaction input [0] has unexpected non-empty signature script", + ) { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestTransactionBuilder_ReplaceUnsignedTransaction_RejectsNonEmptyReplacementWitness( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append( + builder.sigHashArgs, + &inputSigHashArgs{value: 1, scriptCode: []byte{0x51}, witness: true}, + ) + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 0, + }, + Witness: wire.TxWitness{[]byte{0xbb}}, + Sequence: 0xffffffff, + }, + }, + }, + ) + if err == nil { + t.Fatal("expected replacement witness error") + } + + if !strings.Contains( + err.Error(), + "replacement transaction input [0] has unexpected non-empty witness", + ) { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestTransactionBuilder_ReplaceUnsignedTransaction_RejectsMultiElementPreviousWitness( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + previousInput := wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil) + // Pre-signing witness that mimics a P2TR script-path spend: [script, + // controlBlock]. The restoration path supports only zero- or + // single-element previous witnesses today; the multi-element case must + // fail loudly rather than silently dropping data later in signing. + previousInput.Witness = wire.TxWitness{ + []byte{0x51, 0x52}, + []byte{0xc0, 0xab, 0xcd}, + } + builder.internal.AddTxIn(previousInput) + builder.sigHashArgs = append( + builder.sigHashArgs, + &inputSigHashArgs{value: 1, scriptCode: []byte{0x51}, witness: true}, + ) + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + }, + ) + if err == nil { + t.Fatal("expected multi-element witness restoration error") + } + + if !strings.Contains( + err.Error(), + "previous witness has [2] elements", + ) { + t.Fatalf("unexpected error: [%v]", err) + } + if !strings.Contains( + err.Error(), + "only zero- or single-element", + ) { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestTransactionBuilder_UnsignedTransactionIO(t *testing.T) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + for i := range txHash { + txHash[i] = byte(i + 1) + } + const expectedTxIDHex = "201f1e1d1c1b1a191817161514131211100f0e0d0c0b0a090807060504030201" + + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 7), nil, nil)) + builder.sigHashArgs = append(builder.sigHashArgs, &inputSigHashArgs{value: 1234}) + builder.AddOutput(&TransactionOutput{ + Value: 1000, + PublicKeyScript: hexToSlice(t, "0014deadbeef"), + }) + + inputs, outputs, err := builder.UnsignedTransactionIO() + if err != nil { + t.Fatalf("unexpected extraction error: [%v]", err) + } + + if len(inputs) != 1 { + t.Fatalf("unexpected input count: [%d]", len(inputs)) + } + + if inputs[0].TxIDHex != expectedTxIDHex { + t.Fatalf( + "unexpected input txid\nexpected: [%v]\nactual: [%v]", + expectedTxIDHex, + inputs[0].TxIDHex, + ) + } + + if inputs[0].Vout != 7 { + t.Fatalf("unexpected input vout: [%d]", inputs[0].Vout) + } + + if inputs[0].ValueSats != 1234 { + t.Fatalf("unexpected input value: [%d]", inputs[0].ValueSats) + } + + if len(outputs) != 1 { + t.Fatalf("unexpected output count: [%d]", len(outputs)) + } + + if outputs[0].ScriptPubKeyHex != "0014deadbeef" { + t.Fatalf( + "unexpected output script\nexpected: [%v]\nactual: [%v]", + "0014deadbeef", + outputs[0].ScriptPubKeyHex, + ) + } + + if outputs[0].ValueSats != 1000 { + t.Fatalf("unexpected output value: [%d]", outputs[0].ValueSats) + } +} + +func TestTransactionBuilder_UnsignedTransactionIO_RejectsNegativeInputValue( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append(builder.sigHashArgs, &inputSigHashArgs{value: -1}) + builder.AddOutput(&TransactionOutput{ + Value: 1, + PublicKeyScript: hexToSlice(t, "0014aa"), + }) + + _, _, err := builder.UnsignedTransactionIO() + if err == nil { + t.Fatal("expected extraction error") + } +} + +func TestTransactionBuilder_UnsignedTransactionIO_RejectsNegativeOutputValue( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append(builder.sigHashArgs, &inputSigHashArgs{value: 1}) + builder.AddOutput(&TransactionOutput{ + Value: -1, + PublicKeyScript: hexToSlice(t, "0014aa"), + }) + + _, _, err := builder.UnsignedTransactionIO() + if err == nil { + t.Fatal("expected extraction error") + } +} + // The goal of this test is making sure that the TransactionBuilder can // produce proper signature hashes and apply signatures for all input types, // i.e. P2PKH, P2WPKH, P2SH, and P2WSH. This test uses transactions that @@ -473,6 +1476,37 @@ func assertSigHashArgs(t *testing.T, expected, actual *inputSigHashArgs) { actual.scriptCode, ) + if expected.publicKeyScript != nil { + testutils.AssertBytesEqual( + t, + expected.publicKeyScript, + actual.publicKeyScript, + ) + } + + if expected.scriptType != NonStandardScript { + testutils.AssertIntsEqual( + t, + "sighash args script type", + int(expected.scriptType), + int(actual.scriptType), + ) + } + + if expected.taprootMerkleRoot != nil { + if actual.taprootMerkleRoot == nil { + t.Fatal("expected taproot merkle root") + } + + testutils.AssertBytesEqual( + t, + expected.taprootMerkleRoot[:], + actual.taprootMerkleRoot[:], + ) + } else if actual.taprootMerkleRoot != nil { + t.Fatal("unexpected taproot merkle root") + } + testutils.AssertBoolsEqual( t, "sighash args witness flag", diff --git a/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go b/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go index 45bac4a074..5835ba9df7 100644 --- a/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go +++ b/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go @@ -14,13 +14,13 @@ import ( ) // Minimal ABI for EcdsaDkgValidator public constants used by keep-client. -var ecdsaDkgValidatorConstantsABI = mustParseABI(`[ +var ecdsaDkgValidatorConstantsABI = mustParseEcdsaDkgValidatorABI(`[ {"inputs":[],"name":"activeThreshold","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}, {"inputs":[],"name":"groupSize","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}, {"inputs":[],"name":"groupThreshold","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"} ]`) -func mustParseABI(raw string) abi.ABI { +func mustParseEcdsaDkgValidatorABI(raw string) abi.ABI { a, err := abi.JSON(strings.NewReader(raw)) if err != nil { panic(fmt.Sprintf("invalid bundled EcdsaDkgValidator ABI: %v", err)) diff --git a/pkg/chain/ethereum/ethereum.go b/pkg/chain/ethereum/ethereum.go index d0f9657cf0..b0cd64f8fa 100644 --- a/pkg/chain/ethereum/ethereum.go +++ b/pkg/chain/ethereum/ethereum.go @@ -339,18 +339,18 @@ func (bc *baseChain) OperatorKeyPair() ( func (bc *baseChain) GetBlockNumberByTimestamp( timestamp uint64, ) (uint64, error) { - block, err := bc.currentBlock() + block, err := bc.currentBlockHeader() if err != nil { return 0, fmt.Errorf("cannot get current block: [%v]", err) } - if block.Time() < timestamp { + if block.Time < timestamp { return 0, fmt.Errorf("requested timestamp is in the future") } // Corner case shortcut. - if block.Time() == timestamp { - return block.NumberU64(), nil + if block.Time == timestamp { + return block.Number.Uint64(), nil } // The Ethereum average block time (https://etherscan.io/chart/blocktime) @@ -366,9 +366,9 @@ func (bc *baseChain) GetBlockNumberByTimestamp( // the better one. const averageBlockTime = 13 - for block.Time() > timestamp { + for block.Time > timestamp { // timeDiff is always >0 due to the for-loop condition. - timeDiff := block.Time() - timestamp + timeDiff := block.Time - timestamp // blockDiff is an integer whose value can be: // - >=1 if timeDiff >= averageBlockTime // - ==0 if timeDiff < averageBlockTime @@ -380,7 +380,7 @@ func (bc *baseChain) GetBlockNumberByTimestamp( break } - block, err = bc.blockByNumber(block.NumberU64() - blockDiff) + block, err = bc.headerByNumber(block.Number.Uint64() - blockDiff) if err != nil { return 0, fmt.Errorf("cannot get block: [%v]", err) } @@ -393,8 +393,8 @@ func (bc *baseChain) GetBlockNumberByTimestamp( // // First, try to reduce Case 1 by walking forward block by block until // we achieve Case 2 or 3. - for block.Time() < timestamp { - block, err = bc.blockByNumber(block.NumberU64() + 1) + for block.Time < timestamp { + block, err = bc.headerByNumber(block.Number.Uint64() + 1) if err != nil { return 0, fmt.Errorf("cannot get block: [%v]", err) } @@ -402,16 +402,16 @@ func (bc *baseChain) GetBlockNumberByTimestamp( // At this point, only Case 2 or 3 are possible. If we have Case 2, // just get the previous block and compare which one lies closer to // the requested timestamp. - if block.Time() > timestamp { - previousBlock, err := bc.blockByNumber(block.NumberU64() - 1) + if block.Time > timestamp { + previousBlock, err := bc.headerByNumber(block.Number.Uint64() - 1) if err != nil { return 0, fmt.Errorf("cannot get block: [%v]", err) } - return closerBlock(timestamp, previousBlock, block).NumberU64(), nil + return closerBlock(timestamp, previousBlock, block).Number.Uint64(), nil } - return block.NumberU64(), nil + return block.Number.Uint64(), nil } // GetBlockHashByNumber gets the block hash for the given block number. @@ -427,8 +427,8 @@ func (bc *baseChain) GetBlockHashByNumber(blockNumber uint64) ( return header.Hash(), nil } -// currentBlock fetches the current block. -func (bc *baseChain) currentBlock() (*types.Block, error) { +// currentBlockHeader fetches the current block header. +func (bc *baseChain) currentBlockHeader() (*types.Header, error) { // Use the latest header instead of block counter state. Some modern mainnet // blocks contain transaction types not supported by older block-counting // code paths, while this method only needs the latest block number/time as an @@ -441,16 +441,7 @@ func (bc *baseChain) currentBlock() (*types.Block, error) { return nil, err } - return types.NewBlockWithHeader(header), nil -} - -// blockByNumber returns the block for the given block number. Times out -// if the underlying client call takes more than 30 seconds. -func (bc *baseChain) blockByNumber(number uint64) (*types.Block, error) { - ctx, cancelCtx := context.WithTimeout(context.Background(), 30*time.Second) - defer cancelCtx() - - return bc.client.BlockByNumber(ctx, big.NewInt(int64(number))) + return header, nil } // headerByNumber returns the header for the given block number. Times out @@ -465,7 +456,7 @@ func (bc *baseChain) headerByNumber(number uint64) (*types.Header, error) { // closerBlock check timestamps of blocks b1 and b2 and returns the block // whose timestamp lies closer to the requested timestamp. If the distance // is same for both blocks, the block with greater block number is returned. -func closerBlock(timestamp uint64, b1, b2 *types.Block) *types.Block { +func closerBlock(timestamp uint64, b1, b2 *types.Header) *types.Header { abs := func(x int64) int64 { if x < 0 { return -x @@ -473,12 +464,12 @@ func closerBlock(timestamp uint64, b1, b2 *types.Block) *types.Block { return x } - b1Diff := abs(int64(b1.Time() - timestamp)) - b2Diff := abs(int64(b2.Time() - timestamp)) + b1Diff := abs(int64(b1.Time - timestamp)) + b2Diff := abs(int64(b2.Time - timestamp)) // If the differences are same, return the block with greater number. if b1Diff == b2Diff { - if b2.NumberU64() > b1.NumberU64() { + if b2.Number.Uint64() > b1.Number.Uint64() { return b2 } return b1 diff --git a/pkg/chain/ethereum/frost/gen/abi/FrostWalletRegistry.go b/pkg/chain/ethereum/frost/gen/abi/FrostWalletRegistry.go new file mode 100644 index 0000000000..da872a230d --- /dev/null +++ b/pkg/chain/ethereum/frost/gen/abi/FrostWalletRegistry.go @@ -0,0 +1,6361 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package abi + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// FrostDkgParameters is an auto generated low-level Go binding around an user-defined struct. +type FrostDkgParameters struct { + SeedTimeout *big.Int + ResultChallengePeriodLength *big.Int + ResultChallengeExtraGas *big.Int + ResultSubmissionTimeout *big.Int + SubmitterPrecedencePeriodLength *big.Int +} + +// FrostDkgResult is an auto generated low-level Go binding around an user-defined struct. +type FrostDkgResult struct { + SubmitterMemberIndex *big.Int + XOnlyOutputKey [32]byte + MisbehavedMembersIndices []uint8 + Signatures []byte + SigningMembersIndices []*big.Int + Members []uint32 + MembersHash [32]byte +} + +// FrostInactivityClaim is an auto generated low-level Go binding around an user-defined struct. +type FrostInactivityClaim struct { + WalletID [32]byte + InactiveMembersIndices []*big.Int + HeartbeatFailed bool + Signatures []byte + SigningMembersIndices []*big.Int +} + +// FrostRegistryWalletsWallet is an auto generated low-level Go binding around an user-defined struct. +type FrostRegistryWalletsWallet struct { + MembersIdsHash [32]byte + XOnlyOutputKey [32]byte +} + +// FrostWalletRegistryMetaData contains all meta data concerning the FrostWalletRegistry contract. +var FrostWalletRegistryMetaData = &bind.MetaData{ + ABI: "[{\"inputs\":[{\"internalType\":\"contractSortitionPool\",\"name\":\"_sortitionPool\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"LifecycleOwnerNotSet\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"WalletNotRegistered\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"XOnlyOutputKeyAlreadyRegistered\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"XOnlyOutputKeyIsLegacyAlias\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"XOnlyOutputKeyIsZero\",\"type\":\"error\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"}],\"name\":\"AuthorizationDecreaseApproved\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fromAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"toAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"decreasingAt\",\"type\":\"uint64\"}],\"name\":\"AuthorizationDecreaseRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fromAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"toAmount\",\"type\":\"uint96\"}],\"name\":\"AuthorizationIncreased\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"minimumAuthorization\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"authorizationDecreaseDelay\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"authorizationDecreaseChangePeriod\",\"type\":\"uint64\"}],\"name\":\"AuthorizationParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"resultHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"slashingAmount\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"maliciousSubmitter\",\"type\":\"address\"}],\"name\":\"DkgMaliciousResultSlashed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"resultHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"slashingAmount\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"maliciousSubmitter\",\"type\":\"address\"}],\"name\":\"DkgMaliciousResultSlashingFailed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"seedTimeout\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"resultChallengePeriodLength\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"resultChallengeExtraGas\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"resultSubmissionTimeout\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"resultSubmitterPrecedencePeriodLength\",\"type\":\"uint256\"}],\"name\":\"DkgParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"resultHash\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"approver\",\"type\":\"address\"}],\"name\":\"DkgResultApproved\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"resultHash\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"string\",\"name\":\"reason\",\"type\":\"string\"}],\"name\":\"DkgResultChallenged\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"resultHash\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"seed\",\"type\":\"uint256\"},{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"indexed\":false,\"internalType\":\"structFrostDkg.Result\",\"name\":\"result\",\"type\":\"tuple\"}],\"name\":\"DkgResultSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"DkgSeedTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"seed\",\"type\":\"uint256\"}],\"name\":\"DkgStarted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"DkgStateLocked\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"DkgTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"dkgResultSubmissionGas\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"dkgResultApprovalGasOffset\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"notifyOperatorInactivityGasOffset\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"notifySeedTimeoutGasOffset\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"notifyDkgTimeoutNegativeGasOffset\",\"type\":\"uint256\"}],\"name\":\"GasParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldGovernance\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"GovernanceTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"notifier\",\"type\":\"address\"}],\"name\":\"InactivityClaimed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fromAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"toAmount\",\"type\":\"uint96\"}],\"name\":\"InvoluntaryAuthorizationDecreaseFailed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"lifecycleOwner\",\"type\":\"address\"}],\"name\":\"LifecycleOwnerUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"}],\"name\":\"OperatorJoinedSortitionPool\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"}],\"name\":\"OperatorRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"}],\"name\":\"OperatorStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"randomBeacon\",\"type\":\"address\"}],\"name\":\"RandomBeaconUpgraded\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newReimbursementPool\",\"type\":\"address\"}],\"name\":\"ReimbursementPoolUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"maliciousDkgResultNotificationRewardMultiplier\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"sortitionPoolRewardsBanDuration\",\"type\":\"uint256\"}],\"name\":\"RewardParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"amount\",\"type\":\"uint96\"}],\"name\":\"RewardsWithdrawn\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"maliciousDkgResultSlashingAmount\",\"type\":\"uint256\"}],\"name\":\"SlashingParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"}],\"name\":\"WalletClosed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"dkgResultHash\",\"type\":\"bytes32\"}],\"name\":\"WalletCreated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"walletOwner\",\"type\":\"address\"}],\"name\":\"WalletOwnerUpdated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"relayEntry\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"__beaconCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"}],\"name\":\"approveAuthorizationDecrease\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"dkgResult\",\"type\":\"tuple\"}],\"name\":\"approveDkgResult\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"fromAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint96\",\"name\":\"toAmount\",\"type\":\"uint96\"}],\"name\":\"authorizationDecreaseRequested\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"fromAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint96\",\"name\":\"toAmount\",\"type\":\"uint96\"}],\"name\":\"authorizationIncreased\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"authorizationParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"minimumAuthorization\",\"type\":\"uint96\"},{\"internalType\":\"uint64\",\"name\":\"authorizationDecreaseDelay\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"authorizationDecreaseChangePeriod\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"authorizationSource\",\"outputs\":[{\"internalType\":\"contractIFrostAuthorizationSource\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"}],\"name\":\"availableRewards\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"\",\"type\":\"uint96\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"dkgResult\",\"type\":\"tuple\"}],\"name\":\"challengeDkgResult\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"}],\"name\":\"closeWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"dkgParameters\",\"outputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"seedTimeout\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"resultChallengePeriodLength\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"resultChallengeExtraGas\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"resultSubmissionTimeout\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"submitterPrecedencePeriodLength\",\"type\":\"uint256\"}],\"internalType\":\"structFrostDkg.Parameters\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"}],\"name\":\"eligibleStake\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"\",\"type\":\"uint96\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"gasParameters\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"dkgResultSubmissionGas\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"dkgResultApprovalGasOffset\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"notifyOperatorInactivityGasOffset\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"notifySeedTimeoutGasOffset\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"notifyDkgTimeoutNegativeGasOffset\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"}],\"name\":\"getWallet\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"membersIdsHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostRegistryWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getWalletCreationState\",\"outputs\":[{\"internalType\":\"enumFrostDkg.State\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"}],\"name\":\"getWalletXOnlyOutputKey\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"hasDkgTimedOut\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"hasSeedTimedOut\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"name\":\"inactivityClaimNonce\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractFrostDkgValidator\",\"name\":\"_ecdsaDkgValidator\",\"type\":\"address\"},{\"internalType\":\"contractIRandomBeacon\",\"name\":\"_randomBeacon\",\"type\":\"address\"},{\"internalType\":\"contractReimbursementPool\",\"name\":\"_reimbursementPool\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_bridge\",\"type\":\"address\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_authorizationSource\",\"type\":\"address\"}],\"name\":\"initializeV2\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"fromAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint96\",\"name\":\"toAmount\",\"type\":\"uint96\"}],\"name\":\"involuntaryAuthorizationDecrease\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"result\",\"type\":\"tuple\"}],\"name\":\"isDkgResultValid\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"},{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"}],\"name\":\"isOperatorInPool\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"}],\"name\":\"isOperatorUpToDate\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"walletMemberIndex\",\"type\":\"uint256\"}],\"name\":\"isWalletMember\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"}],\"name\":\"isWalletRegistered\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"joinSortitionPool\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"lifecycleOwner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"minimumAuthorization\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"\",\"type\":\"uint96\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"notifyDkgTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"},{\"internalType\":\"uint256[]\",\"name\":\"inactiveMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"bool\",\"name\":\"heartbeatFailed\",\"type\":\"bool\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"}],\"internalType\":\"structFrostInactivity.Claim\",\"name\":\"claim\",\"type\":\"tuple\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint32[]\",\"name\":\"groupMembers\",\"type\":\"uint32[]\"}],\"name\":\"notifyOperatorInactivity\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"notifySeedTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"}],\"name\":\"operatorToStakingProvider\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"}],\"name\":\"pendingAuthorizationDecrease\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"\",\"type\":\"uint96\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"randomBeacon\",\"outputs\":[{\"internalType\":\"contractIRandomBeacon\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"}],\"name\":\"registerOperator\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"name\":\"registered\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reimbursementPool\",\"outputs\":[{\"internalType\":\"contractReimbursementPool\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"}],\"name\":\"remainingAuthorizationDecreaseDelay\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"requestNewWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"rewardParameters\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"maliciousDkgResultNotificationRewardMultiplier\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"sortitionPoolRewardsBanDuration\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"amount\",\"type\":\"uint96\"},{\"internalType\":\"uint256\",\"name\":\"rewardMultiplier\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"notifier\",\"type\":\"address\"},{\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"seize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"selectGroup\",\"outputs\":[{\"internalType\":\"uint32[]\",\"name\":\"\",\"type\":\"uint32[]\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"slashingParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"maliciousDkgResultSlashingAmount\",\"type\":\"uint96\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"sortitionPool\",\"outputs\":[{\"internalType\":\"contractSortitionPool\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"}],\"name\":\"stakingProviderToOperator\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"dkgResult\",\"type\":\"tuple\"}],\"name\":\"submitDkgResult\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"_minimumAuthorization\",\"type\":\"uint96\"},{\"internalType\":\"uint64\",\"name\":\"_authorizationDecreaseDelay\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"_authorizationDecreaseChangePeriod\",\"type\":\"uint64\"}],\"name\":\"updateAuthorizationParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_seedTimeout\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_resultChallengePeriodLength\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_resultChallengeExtraGas\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_resultSubmissionTimeout\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_submitterPrecedencePeriodLength\",\"type\":\"uint256\"}],\"name\":\"updateDkgParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"dkgResultSubmissionGas\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"dkgResultApprovalGasOffset\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"notifyOperatorInactivityGasOffset\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"notifySeedTimeoutGasOffset\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"notifyDkgTimeoutNegativeGasOffset\",\"type\":\"uint256\"}],\"name\":\"updateGasParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_lifecycleOwner\",\"type\":\"address\"}],\"name\":\"updateLifecycleOwner\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"}],\"name\":\"updateOperatorStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractReimbursementPool\",\"name\":\"_reimbursementPool\",\"type\":\"address\"}],\"name\":\"updateReimbursementPool\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"maliciousDkgResultNotificationRewardMultiplier\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"sortitionPoolRewardsBanDuration\",\"type\":\"uint256\"}],\"name\":\"updateRewardParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"maliciousDkgResultSlashingAmount\",\"type\":\"uint96\"}],\"name\":\"updateSlashingParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractIFrostWalletOwner\",\"name\":\"_walletOwner\",\"type\":\"address\"}],\"name\":\"updateWalletOwner\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractIRandomBeacon\",\"name\":\"_randomBeacon\",\"type\":\"address\"}],\"name\":\"upgradeRandomBeacon\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"walletOwner\",\"outputs\":[{\"internalType\":\"contractIFrostWalletOwner\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"recipient\",\"type\":\"address\"}],\"name\":\"withdrawIneligibleRewards\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"stakingProvider\",\"type\":\"address\"}],\"name\":\"withdrawRewards\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", +} + +// FrostWalletRegistryABI is the input ABI used to generate the binding from. +// Deprecated: Use FrostWalletRegistryMetaData.ABI instead. +var FrostWalletRegistryABI = FrostWalletRegistryMetaData.ABI + +// FrostWalletRegistry is an auto generated Go binding around an Ethereum contract. +type FrostWalletRegistry struct { + FrostWalletRegistryCaller // Read-only binding to the contract + FrostWalletRegistryTransactor // Write-only binding to the contract + FrostWalletRegistryFilterer // Log filterer for contract events +} + +// FrostWalletRegistryCaller is an auto generated read-only Go binding around an Ethereum contract. +type FrostWalletRegistryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// FrostWalletRegistryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type FrostWalletRegistryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// FrostWalletRegistryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type FrostWalletRegistryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// FrostWalletRegistrySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type FrostWalletRegistrySession struct { + Contract *FrostWalletRegistry // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// FrostWalletRegistryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type FrostWalletRegistryCallerSession struct { + Contract *FrostWalletRegistryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// FrostWalletRegistryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type FrostWalletRegistryTransactorSession struct { + Contract *FrostWalletRegistryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// FrostWalletRegistryRaw is an auto generated low-level Go binding around an Ethereum contract. +type FrostWalletRegistryRaw struct { + Contract *FrostWalletRegistry // Generic contract binding to access the raw methods on +} + +// FrostWalletRegistryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type FrostWalletRegistryCallerRaw struct { + Contract *FrostWalletRegistryCaller // Generic read-only contract binding to access the raw methods on +} + +// FrostWalletRegistryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type FrostWalletRegistryTransactorRaw struct { + Contract *FrostWalletRegistryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewFrostWalletRegistry creates a new instance of FrostWalletRegistry, bound to a specific deployed contract. +func NewFrostWalletRegistry(address common.Address, backend bind.ContractBackend) (*FrostWalletRegistry, error) { + contract, err := bindFrostWalletRegistry(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &FrostWalletRegistry{FrostWalletRegistryCaller: FrostWalletRegistryCaller{contract: contract}, FrostWalletRegistryTransactor: FrostWalletRegistryTransactor{contract: contract}, FrostWalletRegistryFilterer: FrostWalletRegistryFilterer{contract: contract}}, nil +} + +// NewFrostWalletRegistryCaller creates a new read-only instance of FrostWalletRegistry, bound to a specific deployed contract. +func NewFrostWalletRegistryCaller(address common.Address, caller bind.ContractCaller) (*FrostWalletRegistryCaller, error) { + contract, err := bindFrostWalletRegistry(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &FrostWalletRegistryCaller{contract: contract}, nil +} + +// NewFrostWalletRegistryTransactor creates a new write-only instance of FrostWalletRegistry, bound to a specific deployed contract. +func NewFrostWalletRegistryTransactor(address common.Address, transactor bind.ContractTransactor) (*FrostWalletRegistryTransactor, error) { + contract, err := bindFrostWalletRegistry(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &FrostWalletRegistryTransactor{contract: contract}, nil +} + +// NewFrostWalletRegistryFilterer creates a new log filterer instance of FrostWalletRegistry, bound to a specific deployed contract. +func NewFrostWalletRegistryFilterer(address common.Address, filterer bind.ContractFilterer) (*FrostWalletRegistryFilterer, error) { + contract, err := bindFrostWalletRegistry(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &FrostWalletRegistryFilterer{contract: contract}, nil +} + +// bindFrostWalletRegistry binds a generic wrapper to an already deployed contract. +func bindFrostWalletRegistry(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := FrostWalletRegistryMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_FrostWalletRegistry *FrostWalletRegistryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _FrostWalletRegistry.Contract.FrostWalletRegistryCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_FrostWalletRegistry *FrostWalletRegistryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.FrostWalletRegistryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_FrostWalletRegistry *FrostWalletRegistryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.FrostWalletRegistryTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_FrostWalletRegistry *FrostWalletRegistryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _FrostWalletRegistry.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_FrostWalletRegistry *FrostWalletRegistryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_FrostWalletRegistry *FrostWalletRegistryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.contract.Transact(opts, method, params...) +} + +// AuthorizationParameters is a free data retrieval call binding the contract method 0x7b14729e. +// +// Solidity: function authorizationParameters() view returns(uint96 minimumAuthorization, uint64 authorizationDecreaseDelay, uint64 authorizationDecreaseChangePeriod) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) AuthorizationParameters(opts *bind.CallOpts) (struct { + MinimumAuthorization *big.Int + AuthorizationDecreaseDelay uint64 + AuthorizationDecreaseChangePeriod uint64 +}, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "authorizationParameters") + + outstruct := new(struct { + MinimumAuthorization *big.Int + AuthorizationDecreaseDelay uint64 + AuthorizationDecreaseChangePeriod uint64 + }) + if err != nil { + return *outstruct, err + } + + outstruct.MinimumAuthorization = *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.AuthorizationDecreaseDelay = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.AuthorizationDecreaseChangePeriod = *abi.ConvertType(out[2], new(uint64)).(*uint64) + + return *outstruct, err + +} + +// AuthorizationParameters is a free data retrieval call binding the contract method 0x7b14729e. +// +// Solidity: function authorizationParameters() view returns(uint96 minimumAuthorization, uint64 authorizationDecreaseDelay, uint64 authorizationDecreaseChangePeriod) +func (_FrostWalletRegistry *FrostWalletRegistrySession) AuthorizationParameters() (struct { + MinimumAuthorization *big.Int + AuthorizationDecreaseDelay uint64 + AuthorizationDecreaseChangePeriod uint64 +}, error) { + return _FrostWalletRegistry.Contract.AuthorizationParameters(&_FrostWalletRegistry.CallOpts) +} + +// AuthorizationParameters is a free data retrieval call binding the contract method 0x7b14729e. +// +// Solidity: function authorizationParameters() view returns(uint96 minimumAuthorization, uint64 authorizationDecreaseDelay, uint64 authorizationDecreaseChangePeriod) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) AuthorizationParameters() (struct { + MinimumAuthorization *big.Int + AuthorizationDecreaseDelay uint64 + AuthorizationDecreaseChangePeriod uint64 +}, error) { + return _FrostWalletRegistry.Contract.AuthorizationParameters(&_FrostWalletRegistry.CallOpts) +} + +// AuthorizationSource is a free data retrieval call binding the contract method 0x0a3abae9. +// +// Solidity: function authorizationSource() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) AuthorizationSource(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "authorizationSource") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// AuthorizationSource is a free data retrieval call binding the contract method 0x0a3abae9. +// +// Solidity: function authorizationSource() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistrySession) AuthorizationSource() (common.Address, error) { + return _FrostWalletRegistry.Contract.AuthorizationSource(&_FrostWalletRegistry.CallOpts) +} + +// AuthorizationSource is a free data retrieval call binding the contract method 0x0a3abae9. +// +// Solidity: function authorizationSource() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) AuthorizationSource() (common.Address, error) { + return _FrostWalletRegistry.Contract.AuthorizationSource(&_FrostWalletRegistry.CallOpts) +} + +// AvailableRewards is a free data retrieval call binding the contract method 0xf854a27f. +// +// Solidity: function availableRewards(address stakingProvider) view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) AvailableRewards(opts *bind.CallOpts, stakingProvider common.Address) (*big.Int, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "availableRewards", stakingProvider) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// AvailableRewards is a free data retrieval call binding the contract method 0xf854a27f. +// +// Solidity: function availableRewards(address stakingProvider) view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistrySession) AvailableRewards(stakingProvider common.Address) (*big.Int, error) { + return _FrostWalletRegistry.Contract.AvailableRewards(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// AvailableRewards is a free data retrieval call binding the contract method 0xf854a27f. +// +// Solidity: function availableRewards(address stakingProvider) view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) AvailableRewards(stakingProvider common.Address) (*big.Int, error) { + return _FrostWalletRegistry.Contract.AvailableRewards(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// DkgParameters is a free data retrieval call binding the contract method 0x08aa090b. +// +// Solidity: function dkgParameters() view returns((uint256,uint256,uint256,uint256,uint256)) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) DkgParameters(opts *bind.CallOpts) (FrostDkgParameters, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "dkgParameters") + + if err != nil { + return *new(FrostDkgParameters), err + } + + out0 := *abi.ConvertType(out[0], new(FrostDkgParameters)).(*FrostDkgParameters) + + return out0, err + +} + +// DkgParameters is a free data retrieval call binding the contract method 0x08aa090b. +// +// Solidity: function dkgParameters() view returns((uint256,uint256,uint256,uint256,uint256)) +func (_FrostWalletRegistry *FrostWalletRegistrySession) DkgParameters() (FrostDkgParameters, error) { + return _FrostWalletRegistry.Contract.DkgParameters(&_FrostWalletRegistry.CallOpts) +} + +// DkgParameters is a free data retrieval call binding the contract method 0x08aa090b. +// +// Solidity: function dkgParameters() view returns((uint256,uint256,uint256,uint256,uint256)) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) DkgParameters() (FrostDkgParameters, error) { + return _FrostWalletRegistry.Contract.DkgParameters(&_FrostWalletRegistry.CallOpts) +} + +// EligibleStake is a free data retrieval call binding the contract method 0x7e33cba6. +// +// Solidity: function eligibleStake(address stakingProvider) view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) EligibleStake(opts *bind.CallOpts, stakingProvider common.Address) (*big.Int, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "eligibleStake", stakingProvider) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// EligibleStake is a free data retrieval call binding the contract method 0x7e33cba6. +// +// Solidity: function eligibleStake(address stakingProvider) view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistrySession) EligibleStake(stakingProvider common.Address) (*big.Int, error) { + return _FrostWalletRegistry.Contract.EligibleStake(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// EligibleStake is a free data retrieval call binding the contract method 0x7e33cba6. +// +// Solidity: function eligibleStake(address stakingProvider) view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) EligibleStake(stakingProvider common.Address) (*big.Int, error) { + return _FrostWalletRegistry.Contract.EligibleStake(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// GasParameters is a free data retrieval call binding the contract method 0x88a59590. +// +// Solidity: function gasParameters() view returns(uint256 dkgResultSubmissionGas, uint256 dkgResultApprovalGasOffset, uint256 notifyOperatorInactivityGasOffset, uint256 notifySeedTimeoutGasOffset, uint256 notifyDkgTimeoutNegativeGasOffset) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) GasParameters(opts *bind.CallOpts) (struct { + DkgResultSubmissionGas *big.Int + DkgResultApprovalGasOffset *big.Int + NotifyOperatorInactivityGasOffset *big.Int + NotifySeedTimeoutGasOffset *big.Int + NotifyDkgTimeoutNegativeGasOffset *big.Int +}, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "gasParameters") + + outstruct := new(struct { + DkgResultSubmissionGas *big.Int + DkgResultApprovalGasOffset *big.Int + NotifyOperatorInactivityGasOffset *big.Int + NotifySeedTimeoutGasOffset *big.Int + NotifyDkgTimeoutNegativeGasOffset *big.Int + }) + if err != nil { + return *outstruct, err + } + + outstruct.DkgResultSubmissionGas = *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.DkgResultApprovalGasOffset = *abi.ConvertType(out[1], new(*big.Int)).(**big.Int) + outstruct.NotifyOperatorInactivityGasOffset = *abi.ConvertType(out[2], new(*big.Int)).(**big.Int) + outstruct.NotifySeedTimeoutGasOffset = *abi.ConvertType(out[3], new(*big.Int)).(**big.Int) + outstruct.NotifyDkgTimeoutNegativeGasOffset = *abi.ConvertType(out[4], new(*big.Int)).(**big.Int) + + return *outstruct, err + +} + +// GasParameters is a free data retrieval call binding the contract method 0x88a59590. +// +// Solidity: function gasParameters() view returns(uint256 dkgResultSubmissionGas, uint256 dkgResultApprovalGasOffset, uint256 notifyOperatorInactivityGasOffset, uint256 notifySeedTimeoutGasOffset, uint256 notifyDkgTimeoutNegativeGasOffset) +func (_FrostWalletRegistry *FrostWalletRegistrySession) GasParameters() (struct { + DkgResultSubmissionGas *big.Int + DkgResultApprovalGasOffset *big.Int + NotifyOperatorInactivityGasOffset *big.Int + NotifySeedTimeoutGasOffset *big.Int + NotifyDkgTimeoutNegativeGasOffset *big.Int +}, error) { + return _FrostWalletRegistry.Contract.GasParameters(&_FrostWalletRegistry.CallOpts) +} + +// GasParameters is a free data retrieval call binding the contract method 0x88a59590. +// +// Solidity: function gasParameters() view returns(uint256 dkgResultSubmissionGas, uint256 dkgResultApprovalGasOffset, uint256 notifyOperatorInactivityGasOffset, uint256 notifySeedTimeoutGasOffset, uint256 notifyDkgTimeoutNegativeGasOffset) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) GasParameters() (struct { + DkgResultSubmissionGas *big.Int + DkgResultApprovalGasOffset *big.Int + NotifyOperatorInactivityGasOffset *big.Int + NotifySeedTimeoutGasOffset *big.Int + NotifyDkgTimeoutNegativeGasOffset *big.Int +}, error) { + return _FrostWalletRegistry.Contract.GasParameters(&_FrostWalletRegistry.CallOpts) +} + +// GetWallet is a free data retrieval call binding the contract method 0x789d392a. +// +// Solidity: function getWallet(bytes32 walletID) view returns((bytes32,bytes32)) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) GetWallet(opts *bind.CallOpts, walletID [32]byte) (FrostRegistryWalletsWallet, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "getWallet", walletID) + + if err != nil { + return *new(FrostRegistryWalletsWallet), err + } + + out0 := *abi.ConvertType(out[0], new(FrostRegistryWalletsWallet)).(*FrostRegistryWalletsWallet) + + return out0, err + +} + +// GetWallet is a free data retrieval call binding the contract method 0x789d392a. +// +// Solidity: function getWallet(bytes32 walletID) view returns((bytes32,bytes32)) +func (_FrostWalletRegistry *FrostWalletRegistrySession) GetWallet(walletID [32]byte) (FrostRegistryWalletsWallet, error) { + return _FrostWalletRegistry.Contract.GetWallet(&_FrostWalletRegistry.CallOpts, walletID) +} + +// GetWallet is a free data retrieval call binding the contract method 0x789d392a. +// +// Solidity: function getWallet(bytes32 walletID) view returns((bytes32,bytes32)) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) GetWallet(walletID [32]byte) (FrostRegistryWalletsWallet, error) { + return _FrostWalletRegistry.Contract.GetWallet(&_FrostWalletRegistry.CallOpts, walletID) +} + +// GetWalletCreationState is a free data retrieval call binding the contract method 0xcc562388. +// +// Solidity: function getWalletCreationState() view returns(uint8) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) GetWalletCreationState(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "getWalletCreationState") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// GetWalletCreationState is a free data retrieval call binding the contract method 0xcc562388. +// +// Solidity: function getWalletCreationState() view returns(uint8) +func (_FrostWalletRegistry *FrostWalletRegistrySession) GetWalletCreationState() (uint8, error) { + return _FrostWalletRegistry.Contract.GetWalletCreationState(&_FrostWalletRegistry.CallOpts) +} + +// GetWalletCreationState is a free data retrieval call binding the contract method 0xcc562388. +// +// Solidity: function getWalletCreationState() view returns(uint8) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) GetWalletCreationState() (uint8, error) { + return _FrostWalletRegistry.Contract.GetWalletCreationState(&_FrostWalletRegistry.CallOpts) +} + +// GetWalletXOnlyOutputKey is a free data retrieval call binding the contract method 0x13bd580a. +// +// Solidity: function getWalletXOnlyOutputKey(bytes32 walletID) view returns(bytes32) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) GetWalletXOnlyOutputKey(opts *bind.CallOpts, walletID [32]byte) ([32]byte, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "getWalletXOnlyOutputKey", walletID) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetWalletXOnlyOutputKey is a free data retrieval call binding the contract method 0x13bd580a. +// +// Solidity: function getWalletXOnlyOutputKey(bytes32 walletID) view returns(bytes32) +func (_FrostWalletRegistry *FrostWalletRegistrySession) GetWalletXOnlyOutputKey(walletID [32]byte) ([32]byte, error) { + return _FrostWalletRegistry.Contract.GetWalletXOnlyOutputKey(&_FrostWalletRegistry.CallOpts, walletID) +} + +// GetWalletXOnlyOutputKey is a free data retrieval call binding the contract method 0x13bd580a. +// +// Solidity: function getWalletXOnlyOutputKey(bytes32 walletID) view returns(bytes32) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) GetWalletXOnlyOutputKey(walletID [32]byte) ([32]byte, error) { + return _FrostWalletRegistry.Contract.GetWalletXOnlyOutputKey(&_FrostWalletRegistry.CallOpts, walletID) +} + +// Governance is a free data retrieval call binding the contract method 0x5aa6e675. +// +// Solidity: function governance() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) Governance(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "governance") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// Governance is a free data retrieval call binding the contract method 0x5aa6e675. +// +// Solidity: function governance() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistrySession) Governance() (common.Address, error) { + return _FrostWalletRegistry.Contract.Governance(&_FrostWalletRegistry.CallOpts) +} + +// Governance is a free data retrieval call binding the contract method 0x5aa6e675. +// +// Solidity: function governance() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) Governance() (common.Address, error) { + return _FrostWalletRegistry.Contract.Governance(&_FrostWalletRegistry.CallOpts) +} + +// HasDkgTimedOut is a free data retrieval call binding the contract method 0x68c34948. +// +// Solidity: function hasDkgTimedOut() view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) HasDkgTimedOut(opts *bind.CallOpts) (bool, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "hasDkgTimedOut") + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// HasDkgTimedOut is a free data retrieval call binding the contract method 0x68c34948. +// +// Solidity: function hasDkgTimedOut() view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistrySession) HasDkgTimedOut() (bool, error) { + return _FrostWalletRegistry.Contract.HasDkgTimedOut(&_FrostWalletRegistry.CallOpts) +} + +// HasDkgTimedOut is a free data retrieval call binding the contract method 0x68c34948. +// +// Solidity: function hasDkgTimedOut() view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) HasDkgTimedOut() (bool, error) { + return _FrostWalletRegistry.Contract.HasDkgTimedOut(&_FrostWalletRegistry.CallOpts) +} + +// HasSeedTimedOut is a free data retrieval call binding the contract method 0x770124d3. +// +// Solidity: function hasSeedTimedOut() view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) HasSeedTimedOut(opts *bind.CallOpts) (bool, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "hasSeedTimedOut") + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// HasSeedTimedOut is a free data retrieval call binding the contract method 0x770124d3. +// +// Solidity: function hasSeedTimedOut() view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistrySession) HasSeedTimedOut() (bool, error) { + return _FrostWalletRegistry.Contract.HasSeedTimedOut(&_FrostWalletRegistry.CallOpts) +} + +// HasSeedTimedOut is a free data retrieval call binding the contract method 0x770124d3. +// +// Solidity: function hasSeedTimedOut() view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) HasSeedTimedOut() (bool, error) { + return _FrostWalletRegistry.Contract.HasSeedTimedOut(&_FrostWalletRegistry.CallOpts) +} + +// InactivityClaimNonce is a free data retrieval call binding the contract method 0x830f9e02. +// +// Solidity: function inactivityClaimNonce(bytes32 ) view returns(uint256) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) InactivityClaimNonce(opts *bind.CallOpts, arg0 [32]byte) (*big.Int, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "inactivityClaimNonce", arg0) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// InactivityClaimNonce is a free data retrieval call binding the contract method 0x830f9e02. +// +// Solidity: function inactivityClaimNonce(bytes32 ) view returns(uint256) +func (_FrostWalletRegistry *FrostWalletRegistrySession) InactivityClaimNonce(arg0 [32]byte) (*big.Int, error) { + return _FrostWalletRegistry.Contract.InactivityClaimNonce(&_FrostWalletRegistry.CallOpts, arg0) +} + +// InactivityClaimNonce is a free data retrieval call binding the contract method 0x830f9e02. +// +// Solidity: function inactivityClaimNonce(bytes32 ) view returns(uint256) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) InactivityClaimNonce(arg0 [32]byte) (*big.Int, error) { + return _FrostWalletRegistry.Contract.InactivityClaimNonce(&_FrostWalletRegistry.CallOpts, arg0) +} + +// IsDkgResultValid is a free data retrieval call binding the contract method 0x3b74e062. +// +// Solidity: function isDkgResultValid((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) view returns(bool, string) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) IsDkgResultValid(opts *bind.CallOpts, result FrostDkgResult) (bool, string, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "isDkgResultValid", result) + + if err != nil { + return *new(bool), *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + out1 := *abi.ConvertType(out[1], new(string)).(*string) + + return out0, out1, err + +} + +// IsDkgResultValid is a free data retrieval call binding the contract method 0x3b74e062. +// +// Solidity: function isDkgResultValid((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) view returns(bool, string) +func (_FrostWalletRegistry *FrostWalletRegistrySession) IsDkgResultValid(result FrostDkgResult) (bool, string, error) { + return _FrostWalletRegistry.Contract.IsDkgResultValid(&_FrostWalletRegistry.CallOpts, result) +} + +// IsDkgResultValid is a free data retrieval call binding the contract method 0x3b74e062. +// +// Solidity: function isDkgResultValid((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) view returns(bool, string) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) IsDkgResultValid(result FrostDkgResult) (bool, string, error) { + return _FrostWalletRegistry.Contract.IsDkgResultValid(&_FrostWalletRegistry.CallOpts, result) +} + +// IsOperatorInPool is a free data retrieval call binding the contract method 0xf7186ce0. +// +// Solidity: function isOperatorInPool(address operator) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) IsOperatorInPool(opts *bind.CallOpts, operator common.Address) (bool, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "isOperatorInPool", operator) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsOperatorInPool is a free data retrieval call binding the contract method 0xf7186ce0. +// +// Solidity: function isOperatorInPool(address operator) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistrySession) IsOperatorInPool(operator common.Address) (bool, error) { + return _FrostWalletRegistry.Contract.IsOperatorInPool(&_FrostWalletRegistry.CallOpts, operator) +} + +// IsOperatorInPool is a free data retrieval call binding the contract method 0xf7186ce0. +// +// Solidity: function isOperatorInPool(address operator) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) IsOperatorInPool(operator common.Address) (bool, error) { + return _FrostWalletRegistry.Contract.IsOperatorInPool(&_FrostWalletRegistry.CallOpts, operator) +} + +// IsOperatorUpToDate is a free data retrieval call binding the contract method 0xe686440f. +// +// Solidity: function isOperatorUpToDate(address operator) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) IsOperatorUpToDate(opts *bind.CallOpts, operator common.Address) (bool, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "isOperatorUpToDate", operator) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsOperatorUpToDate is a free data retrieval call binding the contract method 0xe686440f. +// +// Solidity: function isOperatorUpToDate(address operator) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistrySession) IsOperatorUpToDate(operator common.Address) (bool, error) { + return _FrostWalletRegistry.Contract.IsOperatorUpToDate(&_FrostWalletRegistry.CallOpts, operator) +} + +// IsOperatorUpToDate is a free data retrieval call binding the contract method 0xe686440f. +// +// Solidity: function isOperatorUpToDate(address operator) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) IsOperatorUpToDate(operator common.Address) (bool, error) { + return _FrostWalletRegistry.Contract.IsOperatorUpToDate(&_FrostWalletRegistry.CallOpts, operator) +} + +// IsWalletMember is a free data retrieval call binding the contract method 0xdf07ce59. +// +// Solidity: function isWalletMember(bytes32 walletID, uint32[] walletMembersIDs, address operator, uint256 walletMemberIndex) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) IsWalletMember(opts *bind.CallOpts, walletID [32]byte, walletMembersIDs []uint32, operator common.Address, walletMemberIndex *big.Int) (bool, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "isWalletMember", walletID, walletMembersIDs, operator, walletMemberIndex) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsWalletMember is a free data retrieval call binding the contract method 0xdf07ce59. +// +// Solidity: function isWalletMember(bytes32 walletID, uint32[] walletMembersIDs, address operator, uint256 walletMemberIndex) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistrySession) IsWalletMember(walletID [32]byte, walletMembersIDs []uint32, operator common.Address, walletMemberIndex *big.Int) (bool, error) { + return _FrostWalletRegistry.Contract.IsWalletMember(&_FrostWalletRegistry.CallOpts, walletID, walletMembersIDs, operator, walletMemberIndex) +} + +// IsWalletMember is a free data retrieval call binding the contract method 0xdf07ce59. +// +// Solidity: function isWalletMember(bytes32 walletID, uint32[] walletMembersIDs, address operator, uint256 walletMemberIndex) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) IsWalletMember(walletID [32]byte, walletMembersIDs []uint32, operator common.Address, walletMemberIndex *big.Int) (bool, error) { + return _FrostWalletRegistry.Contract.IsWalletMember(&_FrostWalletRegistry.CallOpts, walletID, walletMembersIDs, operator, walletMemberIndex) +} + +// IsWalletRegistered is a free data retrieval call binding the contract method 0x4d99f473. +// +// Solidity: function isWalletRegistered(bytes32 walletID) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) IsWalletRegistered(opts *bind.CallOpts, walletID [32]byte) (bool, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "isWalletRegistered", walletID) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsWalletRegistered is a free data retrieval call binding the contract method 0x4d99f473. +// +// Solidity: function isWalletRegistered(bytes32 walletID) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistrySession) IsWalletRegistered(walletID [32]byte) (bool, error) { + return _FrostWalletRegistry.Contract.IsWalletRegistered(&_FrostWalletRegistry.CallOpts, walletID) +} + +// IsWalletRegistered is a free data retrieval call binding the contract method 0x4d99f473. +// +// Solidity: function isWalletRegistered(bytes32 walletID) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) IsWalletRegistered(walletID [32]byte) (bool, error) { + return _FrostWalletRegistry.Contract.IsWalletRegistered(&_FrostWalletRegistry.CallOpts, walletID) +} + +// LifecycleOwner is a free data retrieval call binding the contract method 0x7780dea1. +// +// Solidity: function lifecycleOwner() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) LifecycleOwner(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "lifecycleOwner") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// LifecycleOwner is a free data retrieval call binding the contract method 0x7780dea1. +// +// Solidity: function lifecycleOwner() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistrySession) LifecycleOwner() (common.Address, error) { + return _FrostWalletRegistry.Contract.LifecycleOwner(&_FrostWalletRegistry.CallOpts) +} + +// LifecycleOwner is a free data retrieval call binding the contract method 0x7780dea1. +// +// Solidity: function lifecycleOwner() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) LifecycleOwner() (common.Address, error) { + return _FrostWalletRegistry.Contract.LifecycleOwner(&_FrostWalletRegistry.CallOpts) +} + +// MinimumAuthorization is a free data retrieval call binding the contract method 0xf0820c92. +// +// Solidity: function minimumAuthorization() view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) MinimumAuthorization(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "minimumAuthorization") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// MinimumAuthorization is a free data retrieval call binding the contract method 0xf0820c92. +// +// Solidity: function minimumAuthorization() view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistrySession) MinimumAuthorization() (*big.Int, error) { + return _FrostWalletRegistry.Contract.MinimumAuthorization(&_FrostWalletRegistry.CallOpts) +} + +// MinimumAuthorization is a free data retrieval call binding the contract method 0xf0820c92. +// +// Solidity: function minimumAuthorization() view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) MinimumAuthorization() (*big.Int, error) { + return _FrostWalletRegistry.Contract.MinimumAuthorization(&_FrostWalletRegistry.CallOpts) +} + +// OperatorToStakingProvider is a free data retrieval call binding the contract method 0xded56d45. +// +// Solidity: function operatorToStakingProvider(address operator) view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) OperatorToStakingProvider(opts *bind.CallOpts, operator common.Address) (common.Address, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "operatorToStakingProvider", operator) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// OperatorToStakingProvider is a free data retrieval call binding the contract method 0xded56d45. +// +// Solidity: function operatorToStakingProvider(address operator) view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistrySession) OperatorToStakingProvider(operator common.Address) (common.Address, error) { + return _FrostWalletRegistry.Contract.OperatorToStakingProvider(&_FrostWalletRegistry.CallOpts, operator) +} + +// OperatorToStakingProvider is a free data retrieval call binding the contract method 0xded56d45. +// +// Solidity: function operatorToStakingProvider(address operator) view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) OperatorToStakingProvider(operator common.Address) (common.Address, error) { + return _FrostWalletRegistry.Contract.OperatorToStakingProvider(&_FrostWalletRegistry.CallOpts, operator) +} + +// PendingAuthorizationDecrease is a free data retrieval call binding the contract method 0xfd2a4788. +// +// Solidity: function pendingAuthorizationDecrease(address stakingProvider) view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) PendingAuthorizationDecrease(opts *bind.CallOpts, stakingProvider common.Address) (*big.Int, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "pendingAuthorizationDecrease", stakingProvider) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// PendingAuthorizationDecrease is a free data retrieval call binding the contract method 0xfd2a4788. +// +// Solidity: function pendingAuthorizationDecrease(address stakingProvider) view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistrySession) PendingAuthorizationDecrease(stakingProvider common.Address) (*big.Int, error) { + return _FrostWalletRegistry.Contract.PendingAuthorizationDecrease(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// PendingAuthorizationDecrease is a free data retrieval call binding the contract method 0xfd2a4788. +// +// Solidity: function pendingAuthorizationDecrease(address stakingProvider) view returns(uint96) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) PendingAuthorizationDecrease(stakingProvider common.Address) (*big.Int, error) { + return _FrostWalletRegistry.Contract.PendingAuthorizationDecrease(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// RandomBeacon is a free data retrieval call binding the contract method 0x153622b3. +// +// Solidity: function randomBeacon() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) RandomBeacon(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "randomBeacon") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// RandomBeacon is a free data retrieval call binding the contract method 0x153622b3. +// +// Solidity: function randomBeacon() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistrySession) RandomBeacon() (common.Address, error) { + return _FrostWalletRegistry.Contract.RandomBeacon(&_FrostWalletRegistry.CallOpts) +} + +// RandomBeacon is a free data retrieval call binding the contract method 0x153622b3. +// +// Solidity: function randomBeacon() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) RandomBeacon() (common.Address, error) { + return _FrostWalletRegistry.Contract.RandomBeacon(&_FrostWalletRegistry.CallOpts) +} + +// Registered is a free data retrieval call binding the contract method 0x5524d548. +// +// Solidity: function registered(bytes32 ) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) Registered(opts *bind.CallOpts, arg0 [32]byte) (bool, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "registered", arg0) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// Registered is a free data retrieval call binding the contract method 0x5524d548. +// +// Solidity: function registered(bytes32 ) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistrySession) Registered(arg0 [32]byte) (bool, error) { + return _FrostWalletRegistry.Contract.Registered(&_FrostWalletRegistry.CallOpts, arg0) +} + +// Registered is a free data retrieval call binding the contract method 0x5524d548. +// +// Solidity: function registered(bytes32 ) view returns(bool) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) Registered(arg0 [32]byte) (bool, error) { + return _FrostWalletRegistry.Contract.Registered(&_FrostWalletRegistry.CallOpts, arg0) +} + +// ReimbursementPool is a free data retrieval call binding the contract method 0xc09975cd. +// +// Solidity: function reimbursementPool() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) ReimbursementPool(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "reimbursementPool") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// ReimbursementPool is a free data retrieval call binding the contract method 0xc09975cd. +// +// Solidity: function reimbursementPool() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistrySession) ReimbursementPool() (common.Address, error) { + return _FrostWalletRegistry.Contract.ReimbursementPool(&_FrostWalletRegistry.CallOpts) +} + +// ReimbursementPool is a free data retrieval call binding the contract method 0xc09975cd. +// +// Solidity: function reimbursementPool() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) ReimbursementPool() (common.Address, error) { + return _FrostWalletRegistry.Contract.ReimbursementPool(&_FrostWalletRegistry.CallOpts) +} + +// RemainingAuthorizationDecreaseDelay is a free data retrieval call binding the contract method 0x9c9de028. +// +// Solidity: function remainingAuthorizationDecreaseDelay(address stakingProvider) view returns(uint64) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) RemainingAuthorizationDecreaseDelay(opts *bind.CallOpts, stakingProvider common.Address) (uint64, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "remainingAuthorizationDecreaseDelay", stakingProvider) + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// RemainingAuthorizationDecreaseDelay is a free data retrieval call binding the contract method 0x9c9de028. +// +// Solidity: function remainingAuthorizationDecreaseDelay(address stakingProvider) view returns(uint64) +func (_FrostWalletRegistry *FrostWalletRegistrySession) RemainingAuthorizationDecreaseDelay(stakingProvider common.Address) (uint64, error) { + return _FrostWalletRegistry.Contract.RemainingAuthorizationDecreaseDelay(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// RemainingAuthorizationDecreaseDelay is a free data retrieval call binding the contract method 0x9c9de028. +// +// Solidity: function remainingAuthorizationDecreaseDelay(address stakingProvider) view returns(uint64) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) RemainingAuthorizationDecreaseDelay(stakingProvider common.Address) (uint64, error) { + return _FrostWalletRegistry.Contract.RemainingAuthorizationDecreaseDelay(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// RewardParameters is a free data retrieval call binding the contract method 0x52902301. +// +// Solidity: function rewardParameters() view returns(uint256 maliciousDkgResultNotificationRewardMultiplier, uint256 sortitionPoolRewardsBanDuration) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) RewardParameters(opts *bind.CallOpts) (struct { + MaliciousDkgResultNotificationRewardMultiplier *big.Int + SortitionPoolRewardsBanDuration *big.Int +}, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "rewardParameters") + + outstruct := new(struct { + MaliciousDkgResultNotificationRewardMultiplier *big.Int + SortitionPoolRewardsBanDuration *big.Int + }) + if err != nil { + return *outstruct, err + } + + outstruct.MaliciousDkgResultNotificationRewardMultiplier = *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.SortitionPoolRewardsBanDuration = *abi.ConvertType(out[1], new(*big.Int)).(**big.Int) + + return *outstruct, err + +} + +// RewardParameters is a free data retrieval call binding the contract method 0x52902301. +// +// Solidity: function rewardParameters() view returns(uint256 maliciousDkgResultNotificationRewardMultiplier, uint256 sortitionPoolRewardsBanDuration) +func (_FrostWalletRegistry *FrostWalletRegistrySession) RewardParameters() (struct { + MaliciousDkgResultNotificationRewardMultiplier *big.Int + SortitionPoolRewardsBanDuration *big.Int +}, error) { + return _FrostWalletRegistry.Contract.RewardParameters(&_FrostWalletRegistry.CallOpts) +} + +// RewardParameters is a free data retrieval call binding the contract method 0x52902301. +// +// Solidity: function rewardParameters() view returns(uint256 maliciousDkgResultNotificationRewardMultiplier, uint256 sortitionPoolRewardsBanDuration) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) RewardParameters() (struct { + MaliciousDkgResultNotificationRewardMultiplier *big.Int + SortitionPoolRewardsBanDuration *big.Int +}, error) { + return _FrostWalletRegistry.Contract.RewardParameters(&_FrostWalletRegistry.CallOpts) +} + +// SelectGroup is a free data retrieval call binding the contract method 0xe03e4535. +// +// Solidity: function selectGroup() view returns(uint32[]) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) SelectGroup(opts *bind.CallOpts) ([]uint32, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "selectGroup") + + if err != nil { + return *new([]uint32), err + } + + out0 := *abi.ConvertType(out[0], new([]uint32)).(*[]uint32) + + return out0, err + +} + +// SelectGroup is a free data retrieval call binding the contract method 0xe03e4535. +// +// Solidity: function selectGroup() view returns(uint32[]) +func (_FrostWalletRegistry *FrostWalletRegistrySession) SelectGroup() ([]uint32, error) { + return _FrostWalletRegistry.Contract.SelectGroup(&_FrostWalletRegistry.CallOpts) +} + +// SelectGroup is a free data retrieval call binding the contract method 0xe03e4535. +// +// Solidity: function selectGroup() view returns(uint32[]) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) SelectGroup() ([]uint32, error) { + return _FrostWalletRegistry.Contract.SelectGroup(&_FrostWalletRegistry.CallOpts) +} + +// SlashingParameters is a free data retrieval call binding the contract method 0x1d35fa63. +// +// Solidity: function slashingParameters() view returns(uint96 maliciousDkgResultSlashingAmount) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) SlashingParameters(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "slashingParameters") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// SlashingParameters is a free data retrieval call binding the contract method 0x1d35fa63. +// +// Solidity: function slashingParameters() view returns(uint96 maliciousDkgResultSlashingAmount) +func (_FrostWalletRegistry *FrostWalletRegistrySession) SlashingParameters() (*big.Int, error) { + return _FrostWalletRegistry.Contract.SlashingParameters(&_FrostWalletRegistry.CallOpts) +} + +// SlashingParameters is a free data retrieval call binding the contract method 0x1d35fa63. +// +// Solidity: function slashingParameters() view returns(uint96 maliciousDkgResultSlashingAmount) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) SlashingParameters() (*big.Int, error) { + return _FrostWalletRegistry.Contract.SlashingParameters(&_FrostWalletRegistry.CallOpts) +} + +// SortitionPool is a free data retrieval call binding the contract method 0xb54a2374. +// +// Solidity: function sortitionPool() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) SortitionPool(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "sortitionPool") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// SortitionPool is a free data retrieval call binding the contract method 0xb54a2374. +// +// Solidity: function sortitionPool() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistrySession) SortitionPool() (common.Address, error) { + return _FrostWalletRegistry.Contract.SortitionPool(&_FrostWalletRegistry.CallOpts) +} + +// SortitionPool is a free data retrieval call binding the contract method 0xb54a2374. +// +// Solidity: function sortitionPool() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) SortitionPool() (common.Address, error) { + return _FrostWalletRegistry.Contract.SortitionPool(&_FrostWalletRegistry.CallOpts) +} + +// StakingProviderToOperator is a free data retrieval call binding the contract method 0xc7c49c98. +// +// Solidity: function stakingProviderToOperator(address stakingProvider) view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) StakingProviderToOperator(opts *bind.CallOpts, stakingProvider common.Address) (common.Address, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "stakingProviderToOperator", stakingProvider) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// StakingProviderToOperator is a free data retrieval call binding the contract method 0xc7c49c98. +// +// Solidity: function stakingProviderToOperator(address stakingProvider) view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistrySession) StakingProviderToOperator(stakingProvider common.Address) (common.Address, error) { + return _FrostWalletRegistry.Contract.StakingProviderToOperator(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// StakingProviderToOperator is a free data retrieval call binding the contract method 0xc7c49c98. +// +// Solidity: function stakingProviderToOperator(address stakingProvider) view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) StakingProviderToOperator(stakingProvider common.Address) (common.Address, error) { + return _FrostWalletRegistry.Contract.StakingProviderToOperator(&_FrostWalletRegistry.CallOpts, stakingProvider) +} + +// WalletOwner is a free data retrieval call binding the contract method 0x1ae879e8. +// +// Solidity: function walletOwner() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCaller) WalletOwner(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _FrostWalletRegistry.contract.Call(opts, &out, "walletOwner") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// WalletOwner is a free data retrieval call binding the contract method 0x1ae879e8. +// +// Solidity: function walletOwner() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistrySession) WalletOwner() (common.Address, error) { + return _FrostWalletRegistry.Contract.WalletOwner(&_FrostWalletRegistry.CallOpts) +} + +// WalletOwner is a free data retrieval call binding the contract method 0x1ae879e8. +// +// Solidity: function walletOwner() view returns(address) +func (_FrostWalletRegistry *FrostWalletRegistryCallerSession) WalletOwner() (common.Address, error) { + return _FrostWalletRegistry.Contract.WalletOwner(&_FrostWalletRegistry.CallOpts) +} + +// BeaconCallback is a paid mutator transaction binding the contract method 0x6febd464. +// +// Solidity: function __beaconCallback(uint256 relayEntry, uint256 ) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) BeaconCallback(opts *bind.TransactOpts, relayEntry *big.Int, arg1 *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "__beaconCallback", relayEntry, arg1) +} + +// BeaconCallback is a paid mutator transaction binding the contract method 0x6febd464. +// +// Solidity: function __beaconCallback(uint256 relayEntry, uint256 ) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) BeaconCallback(relayEntry *big.Int, arg1 *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.BeaconCallback(&_FrostWalletRegistry.TransactOpts, relayEntry, arg1) +} + +// BeaconCallback is a paid mutator transaction binding the contract method 0x6febd464. +// +// Solidity: function __beaconCallback(uint256 relayEntry, uint256 ) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) BeaconCallback(relayEntry *big.Int, arg1 *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.BeaconCallback(&_FrostWalletRegistry.TransactOpts, relayEntry, arg1) +} + +// ApproveAuthorizationDecrease is a paid mutator transaction binding the contract method 0x75e0ae5a. +// +// Solidity: function approveAuthorizationDecrease(address stakingProvider) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) ApproveAuthorizationDecrease(opts *bind.TransactOpts, stakingProvider common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "approveAuthorizationDecrease", stakingProvider) +} + +// ApproveAuthorizationDecrease is a paid mutator transaction binding the contract method 0x75e0ae5a. +// +// Solidity: function approveAuthorizationDecrease(address stakingProvider) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) ApproveAuthorizationDecrease(stakingProvider common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.ApproveAuthorizationDecrease(&_FrostWalletRegistry.TransactOpts, stakingProvider) +} + +// ApproveAuthorizationDecrease is a paid mutator transaction binding the contract method 0x75e0ae5a. +// +// Solidity: function approveAuthorizationDecrease(address stakingProvider) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) ApproveAuthorizationDecrease(stakingProvider common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.ApproveAuthorizationDecrease(&_FrostWalletRegistry.TransactOpts, stakingProvider) +} + +// ApproveDkgResult is a paid mutator transaction binding the contract method 0xcf2feddd. +// +// Solidity: function approveDkgResult((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) dkgResult) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) ApproveDkgResult(opts *bind.TransactOpts, dkgResult FrostDkgResult) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "approveDkgResult", dkgResult) +} + +// ApproveDkgResult is a paid mutator transaction binding the contract method 0xcf2feddd. +// +// Solidity: function approveDkgResult((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) dkgResult) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) ApproveDkgResult(dkgResult FrostDkgResult) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.ApproveDkgResult(&_FrostWalletRegistry.TransactOpts, dkgResult) +} + +// ApproveDkgResult is a paid mutator transaction binding the contract method 0xcf2feddd. +// +// Solidity: function approveDkgResult((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) dkgResult) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) ApproveDkgResult(dkgResult FrostDkgResult) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.ApproveDkgResult(&_FrostWalletRegistry.TransactOpts, dkgResult) +} + +// AuthorizationDecreaseRequested is a paid mutator transaction binding the contract method 0x6a7f7a90. +// +// Solidity: function authorizationDecreaseRequested(address stakingProvider, uint96 fromAmount, uint96 toAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) AuthorizationDecreaseRequested(opts *bind.TransactOpts, stakingProvider common.Address, fromAmount *big.Int, toAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "authorizationDecreaseRequested", stakingProvider, fromAmount, toAmount) +} + +// AuthorizationDecreaseRequested is a paid mutator transaction binding the contract method 0x6a7f7a90. +// +// Solidity: function authorizationDecreaseRequested(address stakingProvider, uint96 fromAmount, uint96 toAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) AuthorizationDecreaseRequested(stakingProvider common.Address, fromAmount *big.Int, toAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.AuthorizationDecreaseRequested(&_FrostWalletRegistry.TransactOpts, stakingProvider, fromAmount, toAmount) +} + +// AuthorizationDecreaseRequested is a paid mutator transaction binding the contract method 0x6a7f7a90. +// +// Solidity: function authorizationDecreaseRequested(address stakingProvider, uint96 fromAmount, uint96 toAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) AuthorizationDecreaseRequested(stakingProvider common.Address, fromAmount *big.Int, toAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.AuthorizationDecreaseRequested(&_FrostWalletRegistry.TransactOpts, stakingProvider, fromAmount, toAmount) +} + +// AuthorizationIncreased is a paid mutator transaction binding the contract method 0xc9bacaad. +// +// Solidity: function authorizationIncreased(address stakingProvider, uint96 fromAmount, uint96 toAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) AuthorizationIncreased(opts *bind.TransactOpts, stakingProvider common.Address, fromAmount *big.Int, toAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "authorizationIncreased", stakingProvider, fromAmount, toAmount) +} + +// AuthorizationIncreased is a paid mutator transaction binding the contract method 0xc9bacaad. +// +// Solidity: function authorizationIncreased(address stakingProvider, uint96 fromAmount, uint96 toAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) AuthorizationIncreased(stakingProvider common.Address, fromAmount *big.Int, toAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.AuthorizationIncreased(&_FrostWalletRegistry.TransactOpts, stakingProvider, fromAmount, toAmount) +} + +// AuthorizationIncreased is a paid mutator transaction binding the contract method 0xc9bacaad. +// +// Solidity: function authorizationIncreased(address stakingProvider, uint96 fromAmount, uint96 toAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) AuthorizationIncreased(stakingProvider common.Address, fromAmount *big.Int, toAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.AuthorizationIncreased(&_FrostWalletRegistry.TransactOpts, stakingProvider, fromAmount, toAmount) +} + +// ChallengeDkgResult is a paid mutator transaction binding the contract method 0x24ac833e. +// +// Solidity: function challengeDkgResult((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) dkgResult) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) ChallengeDkgResult(opts *bind.TransactOpts, dkgResult FrostDkgResult) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "challengeDkgResult", dkgResult) +} + +// ChallengeDkgResult is a paid mutator transaction binding the contract method 0x24ac833e. +// +// Solidity: function challengeDkgResult((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) dkgResult) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) ChallengeDkgResult(dkgResult FrostDkgResult) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.ChallengeDkgResult(&_FrostWalletRegistry.TransactOpts, dkgResult) +} + +// ChallengeDkgResult is a paid mutator transaction binding the contract method 0x24ac833e. +// +// Solidity: function challengeDkgResult((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) dkgResult) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) ChallengeDkgResult(dkgResult FrostDkgResult) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.ChallengeDkgResult(&_FrostWalletRegistry.TransactOpts, dkgResult) +} + +// CloseWallet is a paid mutator transaction binding the contract method 0x343bb927. +// +// Solidity: function closeWallet(bytes32 walletID) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) CloseWallet(opts *bind.TransactOpts, walletID [32]byte) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "closeWallet", walletID) +} + +// CloseWallet is a paid mutator transaction binding the contract method 0x343bb927. +// +// Solidity: function closeWallet(bytes32 walletID) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) CloseWallet(walletID [32]byte) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.CloseWallet(&_FrostWalletRegistry.TransactOpts, walletID) +} + +// CloseWallet is a paid mutator transaction binding the contract method 0x343bb927. +// +// Solidity: function closeWallet(bytes32 walletID) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) CloseWallet(walletID [32]byte) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.CloseWallet(&_FrostWalletRegistry.TransactOpts, walletID) +} + +// Initialize is a paid mutator transaction binding the contract method 0xf8c8765e. +// +// Solidity: function initialize(address _ecdsaDkgValidator, address _randomBeacon, address _reimbursementPool, address _bridge) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) Initialize(opts *bind.TransactOpts, _ecdsaDkgValidator common.Address, _randomBeacon common.Address, _reimbursementPool common.Address, _bridge common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "initialize", _ecdsaDkgValidator, _randomBeacon, _reimbursementPool, _bridge) +} + +// Initialize is a paid mutator transaction binding the contract method 0xf8c8765e. +// +// Solidity: function initialize(address _ecdsaDkgValidator, address _randomBeacon, address _reimbursementPool, address _bridge) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) Initialize(_ecdsaDkgValidator common.Address, _randomBeacon common.Address, _reimbursementPool common.Address, _bridge common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.Initialize(&_FrostWalletRegistry.TransactOpts, _ecdsaDkgValidator, _randomBeacon, _reimbursementPool, _bridge) +} + +// Initialize is a paid mutator transaction binding the contract method 0xf8c8765e. +// +// Solidity: function initialize(address _ecdsaDkgValidator, address _randomBeacon, address _reimbursementPool, address _bridge) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) Initialize(_ecdsaDkgValidator common.Address, _randomBeacon common.Address, _reimbursementPool common.Address, _bridge common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.Initialize(&_FrostWalletRegistry.TransactOpts, _ecdsaDkgValidator, _randomBeacon, _reimbursementPool, _bridge) +} + +// InitializeV2 is a paid mutator transaction binding the contract method 0x29b6eca9. +// +// Solidity: function initializeV2(address _authorizationSource) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) InitializeV2(opts *bind.TransactOpts, _authorizationSource common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "initializeV2", _authorizationSource) +} + +// InitializeV2 is a paid mutator transaction binding the contract method 0x29b6eca9. +// +// Solidity: function initializeV2(address _authorizationSource) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) InitializeV2(_authorizationSource common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.InitializeV2(&_FrostWalletRegistry.TransactOpts, _authorizationSource) +} + +// InitializeV2 is a paid mutator transaction binding the contract method 0x29b6eca9. +// +// Solidity: function initializeV2(address _authorizationSource) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) InitializeV2(_authorizationSource common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.InitializeV2(&_FrostWalletRegistry.TransactOpts, _authorizationSource) +} + +// InvoluntaryAuthorizationDecrease is a paid mutator transaction binding the contract method 0x14a85474. +// +// Solidity: function involuntaryAuthorizationDecrease(address stakingProvider, uint96 fromAmount, uint96 toAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) InvoluntaryAuthorizationDecrease(opts *bind.TransactOpts, stakingProvider common.Address, fromAmount *big.Int, toAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "involuntaryAuthorizationDecrease", stakingProvider, fromAmount, toAmount) +} + +// InvoluntaryAuthorizationDecrease is a paid mutator transaction binding the contract method 0x14a85474. +// +// Solidity: function involuntaryAuthorizationDecrease(address stakingProvider, uint96 fromAmount, uint96 toAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) InvoluntaryAuthorizationDecrease(stakingProvider common.Address, fromAmount *big.Int, toAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.InvoluntaryAuthorizationDecrease(&_FrostWalletRegistry.TransactOpts, stakingProvider, fromAmount, toAmount) +} + +// InvoluntaryAuthorizationDecrease is a paid mutator transaction binding the contract method 0x14a85474. +// +// Solidity: function involuntaryAuthorizationDecrease(address stakingProvider, uint96 fromAmount, uint96 toAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) InvoluntaryAuthorizationDecrease(stakingProvider common.Address, fromAmount *big.Int, toAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.InvoluntaryAuthorizationDecrease(&_FrostWalletRegistry.TransactOpts, stakingProvider, fromAmount, toAmount) +} + +// JoinSortitionPool is a paid mutator transaction binding the contract method 0x167f0517. +// +// Solidity: function joinSortitionPool() returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) JoinSortitionPool(opts *bind.TransactOpts) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "joinSortitionPool") +} + +// JoinSortitionPool is a paid mutator transaction binding the contract method 0x167f0517. +// +// Solidity: function joinSortitionPool() returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) JoinSortitionPool() (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.JoinSortitionPool(&_FrostWalletRegistry.TransactOpts) +} + +// JoinSortitionPool is a paid mutator transaction binding the contract method 0x167f0517. +// +// Solidity: function joinSortitionPool() returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) JoinSortitionPool() (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.JoinSortitionPool(&_FrostWalletRegistry.TransactOpts) +} + +// NotifyDkgTimeout is a paid mutator transaction binding the contract method 0xd855c631. +// +// Solidity: function notifyDkgTimeout() returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) NotifyDkgTimeout(opts *bind.TransactOpts) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "notifyDkgTimeout") +} + +// NotifyDkgTimeout is a paid mutator transaction binding the contract method 0xd855c631. +// +// Solidity: function notifyDkgTimeout() returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) NotifyDkgTimeout() (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.NotifyDkgTimeout(&_FrostWalletRegistry.TransactOpts) +} + +// NotifyDkgTimeout is a paid mutator transaction binding the contract method 0xd855c631. +// +// Solidity: function notifyDkgTimeout() returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) NotifyDkgTimeout() (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.NotifyDkgTimeout(&_FrostWalletRegistry.TransactOpts) +} + +// NotifyOperatorInactivity is a paid mutator transaction binding the contract method 0x9879d19b. +// +// Solidity: function notifyOperatorInactivity((bytes32,uint256[],bool,bytes,uint256[]) claim, uint256 nonce, uint32[] groupMembers) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) NotifyOperatorInactivity(opts *bind.TransactOpts, claim FrostInactivityClaim, nonce *big.Int, groupMembers []uint32) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "notifyOperatorInactivity", claim, nonce, groupMembers) +} + +// NotifyOperatorInactivity is a paid mutator transaction binding the contract method 0x9879d19b. +// +// Solidity: function notifyOperatorInactivity((bytes32,uint256[],bool,bytes,uint256[]) claim, uint256 nonce, uint32[] groupMembers) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) NotifyOperatorInactivity(claim FrostInactivityClaim, nonce *big.Int, groupMembers []uint32) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.NotifyOperatorInactivity(&_FrostWalletRegistry.TransactOpts, claim, nonce, groupMembers) +} + +// NotifyOperatorInactivity is a paid mutator transaction binding the contract method 0x9879d19b. +// +// Solidity: function notifyOperatorInactivity((bytes32,uint256[],bool,bytes,uint256[]) claim, uint256 nonce, uint32[] groupMembers) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) NotifyOperatorInactivity(claim FrostInactivityClaim, nonce *big.Int, groupMembers []uint32) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.NotifyOperatorInactivity(&_FrostWalletRegistry.TransactOpts, claim, nonce, groupMembers) +} + +// NotifySeedTimeout is a paid mutator transaction binding the contract method 0xb13b55b2. +// +// Solidity: function notifySeedTimeout() returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) NotifySeedTimeout(opts *bind.TransactOpts) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "notifySeedTimeout") +} + +// NotifySeedTimeout is a paid mutator transaction binding the contract method 0xb13b55b2. +// +// Solidity: function notifySeedTimeout() returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) NotifySeedTimeout() (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.NotifySeedTimeout(&_FrostWalletRegistry.TransactOpts) +} + +// NotifySeedTimeout is a paid mutator transaction binding the contract method 0xb13b55b2. +// +// Solidity: function notifySeedTimeout() returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) NotifySeedTimeout() (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.NotifySeedTimeout(&_FrostWalletRegistry.TransactOpts) +} + +// RegisterOperator is a paid mutator transaction binding the contract method 0x3682a450. +// +// Solidity: function registerOperator(address operator) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) RegisterOperator(opts *bind.TransactOpts, operator common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "registerOperator", operator) +} + +// RegisterOperator is a paid mutator transaction binding the contract method 0x3682a450. +// +// Solidity: function registerOperator(address operator) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) RegisterOperator(operator common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.RegisterOperator(&_FrostWalletRegistry.TransactOpts, operator) +} + +// RegisterOperator is a paid mutator transaction binding the contract method 0x3682a450. +// +// Solidity: function registerOperator(address operator) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) RegisterOperator(operator common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.RegisterOperator(&_FrostWalletRegistry.TransactOpts, operator) +} + +// RequestNewWallet is a paid mutator transaction binding the contract method 0x72cc8c6d. +// +// Solidity: function requestNewWallet() returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) RequestNewWallet(opts *bind.TransactOpts) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "requestNewWallet") +} + +// RequestNewWallet is a paid mutator transaction binding the contract method 0x72cc8c6d. +// +// Solidity: function requestNewWallet() returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) RequestNewWallet() (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.RequestNewWallet(&_FrostWalletRegistry.TransactOpts) +} + +// RequestNewWallet is a paid mutator transaction binding the contract method 0x72cc8c6d. +// +// Solidity: function requestNewWallet() returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) RequestNewWallet() (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.RequestNewWallet(&_FrostWalletRegistry.TransactOpts) +} + +// Seize is a paid mutator transaction binding the contract method 0xd8dc404d. +// +// Solidity: function seize(uint96 amount, uint256 rewardMultiplier, address notifier, bytes32 walletID, uint32[] walletMembersIDs) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) Seize(opts *bind.TransactOpts, amount *big.Int, rewardMultiplier *big.Int, notifier common.Address, walletID [32]byte, walletMembersIDs []uint32) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "seize", amount, rewardMultiplier, notifier, walletID, walletMembersIDs) +} + +// Seize is a paid mutator transaction binding the contract method 0xd8dc404d. +// +// Solidity: function seize(uint96 amount, uint256 rewardMultiplier, address notifier, bytes32 walletID, uint32[] walletMembersIDs) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) Seize(amount *big.Int, rewardMultiplier *big.Int, notifier common.Address, walletID [32]byte, walletMembersIDs []uint32) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.Seize(&_FrostWalletRegistry.TransactOpts, amount, rewardMultiplier, notifier, walletID, walletMembersIDs) +} + +// Seize is a paid mutator transaction binding the contract method 0xd8dc404d. +// +// Solidity: function seize(uint96 amount, uint256 rewardMultiplier, address notifier, bytes32 walletID, uint32[] walletMembersIDs) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) Seize(amount *big.Int, rewardMultiplier *big.Int, notifier common.Address, walletID [32]byte, walletMembersIDs []uint32) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.Seize(&_FrostWalletRegistry.TransactOpts, amount, rewardMultiplier, notifier, walletID, walletMembersIDs) +} + +// SubmitDkgResult is a paid mutator transaction binding the contract method 0x55129e3a. +// +// Solidity: function submitDkgResult((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) dkgResult) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) SubmitDkgResult(opts *bind.TransactOpts, dkgResult FrostDkgResult) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "submitDkgResult", dkgResult) +} + +// SubmitDkgResult is a paid mutator transaction binding the contract method 0x55129e3a. +// +// Solidity: function submitDkgResult((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) dkgResult) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) SubmitDkgResult(dkgResult FrostDkgResult) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.SubmitDkgResult(&_FrostWalletRegistry.TransactOpts, dkgResult) +} + +// SubmitDkgResult is a paid mutator transaction binding the contract method 0x55129e3a. +// +// Solidity: function submitDkgResult((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) dkgResult) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) SubmitDkgResult(dkgResult FrostDkgResult) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.SubmitDkgResult(&_FrostWalletRegistry.TransactOpts, dkgResult) +} + +// TransferGovernance is a paid mutator transaction binding the contract method 0xd38bfff4. +// +// Solidity: function transferGovernance(address newGovernance) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) TransferGovernance(opts *bind.TransactOpts, newGovernance common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "transferGovernance", newGovernance) +} + +// TransferGovernance is a paid mutator transaction binding the contract method 0xd38bfff4. +// +// Solidity: function transferGovernance(address newGovernance) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) TransferGovernance(newGovernance common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.TransferGovernance(&_FrostWalletRegistry.TransactOpts, newGovernance) +} + +// TransferGovernance is a paid mutator transaction binding the contract method 0xd38bfff4. +// +// Solidity: function transferGovernance(address newGovernance) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) TransferGovernance(newGovernance common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.TransferGovernance(&_FrostWalletRegistry.TransactOpts, newGovernance) +} + +// UpdateAuthorizationParameters is a paid mutator transaction binding the contract method 0xa04e2980. +// +// Solidity: function updateAuthorizationParameters(uint96 _minimumAuthorization, uint64 _authorizationDecreaseDelay, uint64 _authorizationDecreaseChangePeriod) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpdateAuthorizationParameters(opts *bind.TransactOpts, _minimumAuthorization *big.Int, _authorizationDecreaseDelay uint64, _authorizationDecreaseChangePeriod uint64) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "updateAuthorizationParameters", _minimumAuthorization, _authorizationDecreaseDelay, _authorizationDecreaseChangePeriod) +} + +// UpdateAuthorizationParameters is a paid mutator transaction binding the contract method 0xa04e2980. +// +// Solidity: function updateAuthorizationParameters(uint96 _minimumAuthorization, uint64 _authorizationDecreaseDelay, uint64 _authorizationDecreaseChangePeriod) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpdateAuthorizationParameters(_minimumAuthorization *big.Int, _authorizationDecreaseDelay uint64, _authorizationDecreaseChangePeriod uint64) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateAuthorizationParameters(&_FrostWalletRegistry.TransactOpts, _minimumAuthorization, _authorizationDecreaseDelay, _authorizationDecreaseChangePeriod) +} + +// UpdateAuthorizationParameters is a paid mutator transaction binding the contract method 0xa04e2980. +// +// Solidity: function updateAuthorizationParameters(uint96 _minimumAuthorization, uint64 _authorizationDecreaseDelay, uint64 _authorizationDecreaseChangePeriod) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpdateAuthorizationParameters(_minimumAuthorization *big.Int, _authorizationDecreaseDelay uint64, _authorizationDecreaseChangePeriod uint64) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateAuthorizationParameters(&_FrostWalletRegistry.TransactOpts, _minimumAuthorization, _authorizationDecreaseDelay, _authorizationDecreaseChangePeriod) +} + +// UpdateDkgParameters is a paid mutator transaction binding the contract method 0x8dcbdf4a. +// +// Solidity: function updateDkgParameters(uint256 _seedTimeout, uint256 _resultChallengePeriodLength, uint256 _resultChallengeExtraGas, uint256 _resultSubmissionTimeout, uint256 _submitterPrecedencePeriodLength) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpdateDkgParameters(opts *bind.TransactOpts, _seedTimeout *big.Int, _resultChallengePeriodLength *big.Int, _resultChallengeExtraGas *big.Int, _resultSubmissionTimeout *big.Int, _submitterPrecedencePeriodLength *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "updateDkgParameters", _seedTimeout, _resultChallengePeriodLength, _resultChallengeExtraGas, _resultSubmissionTimeout, _submitterPrecedencePeriodLength) +} + +// UpdateDkgParameters is a paid mutator transaction binding the contract method 0x8dcbdf4a. +// +// Solidity: function updateDkgParameters(uint256 _seedTimeout, uint256 _resultChallengePeriodLength, uint256 _resultChallengeExtraGas, uint256 _resultSubmissionTimeout, uint256 _submitterPrecedencePeriodLength) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpdateDkgParameters(_seedTimeout *big.Int, _resultChallengePeriodLength *big.Int, _resultChallengeExtraGas *big.Int, _resultSubmissionTimeout *big.Int, _submitterPrecedencePeriodLength *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateDkgParameters(&_FrostWalletRegistry.TransactOpts, _seedTimeout, _resultChallengePeriodLength, _resultChallengeExtraGas, _resultSubmissionTimeout, _submitterPrecedencePeriodLength) +} + +// UpdateDkgParameters is a paid mutator transaction binding the contract method 0x8dcbdf4a. +// +// Solidity: function updateDkgParameters(uint256 _seedTimeout, uint256 _resultChallengePeriodLength, uint256 _resultChallengeExtraGas, uint256 _resultSubmissionTimeout, uint256 _submitterPrecedencePeriodLength) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpdateDkgParameters(_seedTimeout *big.Int, _resultChallengePeriodLength *big.Int, _resultChallengeExtraGas *big.Int, _resultSubmissionTimeout *big.Int, _submitterPrecedencePeriodLength *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateDkgParameters(&_FrostWalletRegistry.TransactOpts, _seedTimeout, _resultChallengePeriodLength, _resultChallengeExtraGas, _resultSubmissionTimeout, _submitterPrecedencePeriodLength) +} + +// UpdateGasParameters is a paid mutator transaction binding the contract method 0xc88e70f4. +// +// Solidity: function updateGasParameters(uint256 dkgResultSubmissionGas, uint256 dkgResultApprovalGasOffset, uint256 notifyOperatorInactivityGasOffset, uint256 notifySeedTimeoutGasOffset, uint256 notifyDkgTimeoutNegativeGasOffset) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpdateGasParameters(opts *bind.TransactOpts, dkgResultSubmissionGas *big.Int, dkgResultApprovalGasOffset *big.Int, notifyOperatorInactivityGasOffset *big.Int, notifySeedTimeoutGasOffset *big.Int, notifyDkgTimeoutNegativeGasOffset *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "updateGasParameters", dkgResultSubmissionGas, dkgResultApprovalGasOffset, notifyOperatorInactivityGasOffset, notifySeedTimeoutGasOffset, notifyDkgTimeoutNegativeGasOffset) +} + +// UpdateGasParameters is a paid mutator transaction binding the contract method 0xc88e70f4. +// +// Solidity: function updateGasParameters(uint256 dkgResultSubmissionGas, uint256 dkgResultApprovalGasOffset, uint256 notifyOperatorInactivityGasOffset, uint256 notifySeedTimeoutGasOffset, uint256 notifyDkgTimeoutNegativeGasOffset) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpdateGasParameters(dkgResultSubmissionGas *big.Int, dkgResultApprovalGasOffset *big.Int, notifyOperatorInactivityGasOffset *big.Int, notifySeedTimeoutGasOffset *big.Int, notifyDkgTimeoutNegativeGasOffset *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateGasParameters(&_FrostWalletRegistry.TransactOpts, dkgResultSubmissionGas, dkgResultApprovalGasOffset, notifyOperatorInactivityGasOffset, notifySeedTimeoutGasOffset, notifyDkgTimeoutNegativeGasOffset) +} + +// UpdateGasParameters is a paid mutator transaction binding the contract method 0xc88e70f4. +// +// Solidity: function updateGasParameters(uint256 dkgResultSubmissionGas, uint256 dkgResultApprovalGasOffset, uint256 notifyOperatorInactivityGasOffset, uint256 notifySeedTimeoutGasOffset, uint256 notifyDkgTimeoutNegativeGasOffset) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpdateGasParameters(dkgResultSubmissionGas *big.Int, dkgResultApprovalGasOffset *big.Int, notifyOperatorInactivityGasOffset *big.Int, notifySeedTimeoutGasOffset *big.Int, notifyDkgTimeoutNegativeGasOffset *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateGasParameters(&_FrostWalletRegistry.TransactOpts, dkgResultSubmissionGas, dkgResultApprovalGasOffset, notifyOperatorInactivityGasOffset, notifySeedTimeoutGasOffset, notifyDkgTimeoutNegativeGasOffset) +} + +// UpdateLifecycleOwner is a paid mutator transaction binding the contract method 0x5c776294. +// +// Solidity: function updateLifecycleOwner(address _lifecycleOwner) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpdateLifecycleOwner(opts *bind.TransactOpts, _lifecycleOwner common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "updateLifecycleOwner", _lifecycleOwner) +} + +// UpdateLifecycleOwner is a paid mutator transaction binding the contract method 0x5c776294. +// +// Solidity: function updateLifecycleOwner(address _lifecycleOwner) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpdateLifecycleOwner(_lifecycleOwner common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateLifecycleOwner(&_FrostWalletRegistry.TransactOpts, _lifecycleOwner) +} + +// UpdateLifecycleOwner is a paid mutator transaction binding the contract method 0x5c776294. +// +// Solidity: function updateLifecycleOwner(address _lifecycleOwner) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpdateLifecycleOwner(_lifecycleOwner common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateLifecycleOwner(&_FrostWalletRegistry.TransactOpts, _lifecycleOwner) +} + +// UpdateOperatorStatus is a paid mutator transaction binding the contract method 0x1c5b0762. +// +// Solidity: function updateOperatorStatus(address operator) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpdateOperatorStatus(opts *bind.TransactOpts, operator common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "updateOperatorStatus", operator) +} + +// UpdateOperatorStatus is a paid mutator transaction binding the contract method 0x1c5b0762. +// +// Solidity: function updateOperatorStatus(address operator) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpdateOperatorStatus(operator common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateOperatorStatus(&_FrostWalletRegistry.TransactOpts, operator) +} + +// UpdateOperatorStatus is a paid mutator transaction binding the contract method 0x1c5b0762. +// +// Solidity: function updateOperatorStatus(address operator) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpdateOperatorStatus(operator common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateOperatorStatus(&_FrostWalletRegistry.TransactOpts, operator) +} + +// UpdateReimbursementPool is a paid mutator transaction binding the contract method 0x7b35b4e6. +// +// Solidity: function updateReimbursementPool(address _reimbursementPool) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpdateReimbursementPool(opts *bind.TransactOpts, _reimbursementPool common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "updateReimbursementPool", _reimbursementPool) +} + +// UpdateReimbursementPool is a paid mutator transaction binding the contract method 0x7b35b4e6. +// +// Solidity: function updateReimbursementPool(address _reimbursementPool) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpdateReimbursementPool(_reimbursementPool common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateReimbursementPool(&_FrostWalletRegistry.TransactOpts, _reimbursementPool) +} + +// UpdateReimbursementPool is a paid mutator transaction binding the contract method 0x7b35b4e6. +// +// Solidity: function updateReimbursementPool(address _reimbursementPool) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpdateReimbursementPool(_reimbursementPool common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateReimbursementPool(&_FrostWalletRegistry.TransactOpts, _reimbursementPool) +} + +// UpdateRewardParameters is a paid mutator transaction binding the contract method 0x6c9ecd64. +// +// Solidity: function updateRewardParameters(uint256 maliciousDkgResultNotificationRewardMultiplier, uint256 sortitionPoolRewardsBanDuration) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpdateRewardParameters(opts *bind.TransactOpts, maliciousDkgResultNotificationRewardMultiplier *big.Int, sortitionPoolRewardsBanDuration *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "updateRewardParameters", maliciousDkgResultNotificationRewardMultiplier, sortitionPoolRewardsBanDuration) +} + +// UpdateRewardParameters is a paid mutator transaction binding the contract method 0x6c9ecd64. +// +// Solidity: function updateRewardParameters(uint256 maliciousDkgResultNotificationRewardMultiplier, uint256 sortitionPoolRewardsBanDuration) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpdateRewardParameters(maliciousDkgResultNotificationRewardMultiplier *big.Int, sortitionPoolRewardsBanDuration *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateRewardParameters(&_FrostWalletRegistry.TransactOpts, maliciousDkgResultNotificationRewardMultiplier, sortitionPoolRewardsBanDuration) +} + +// UpdateRewardParameters is a paid mutator transaction binding the contract method 0x6c9ecd64. +// +// Solidity: function updateRewardParameters(uint256 maliciousDkgResultNotificationRewardMultiplier, uint256 sortitionPoolRewardsBanDuration) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpdateRewardParameters(maliciousDkgResultNotificationRewardMultiplier *big.Int, sortitionPoolRewardsBanDuration *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateRewardParameters(&_FrostWalletRegistry.TransactOpts, maliciousDkgResultNotificationRewardMultiplier, sortitionPoolRewardsBanDuration) +} + +// UpdateSlashingParameters is a paid mutator transaction binding the contract method 0x227fd44f. +// +// Solidity: function updateSlashingParameters(uint96 maliciousDkgResultSlashingAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpdateSlashingParameters(opts *bind.TransactOpts, maliciousDkgResultSlashingAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "updateSlashingParameters", maliciousDkgResultSlashingAmount) +} + +// UpdateSlashingParameters is a paid mutator transaction binding the contract method 0x227fd44f. +// +// Solidity: function updateSlashingParameters(uint96 maliciousDkgResultSlashingAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpdateSlashingParameters(maliciousDkgResultSlashingAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateSlashingParameters(&_FrostWalletRegistry.TransactOpts, maliciousDkgResultSlashingAmount) +} + +// UpdateSlashingParameters is a paid mutator transaction binding the contract method 0x227fd44f. +// +// Solidity: function updateSlashingParameters(uint96 maliciousDkgResultSlashingAmount) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpdateSlashingParameters(maliciousDkgResultSlashingAmount *big.Int) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateSlashingParameters(&_FrostWalletRegistry.TransactOpts, maliciousDkgResultSlashingAmount) +} + +// UpdateWalletOwner is a paid mutator transaction binding the contract method 0xd0bcc0e3. +// +// Solidity: function updateWalletOwner(address _walletOwner) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpdateWalletOwner(opts *bind.TransactOpts, _walletOwner common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "updateWalletOwner", _walletOwner) +} + +// UpdateWalletOwner is a paid mutator transaction binding the contract method 0xd0bcc0e3. +// +// Solidity: function updateWalletOwner(address _walletOwner) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpdateWalletOwner(_walletOwner common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateWalletOwner(&_FrostWalletRegistry.TransactOpts, _walletOwner) +} + +// UpdateWalletOwner is a paid mutator transaction binding the contract method 0xd0bcc0e3. +// +// Solidity: function updateWalletOwner(address _walletOwner) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpdateWalletOwner(_walletOwner common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpdateWalletOwner(&_FrostWalletRegistry.TransactOpts, _walletOwner) +} + +// UpgradeRandomBeacon is a paid mutator transaction binding the contract method 0x6b5f2bff. +// +// Solidity: function upgradeRandomBeacon(address _randomBeacon) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) UpgradeRandomBeacon(opts *bind.TransactOpts, _randomBeacon common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "upgradeRandomBeacon", _randomBeacon) +} + +// UpgradeRandomBeacon is a paid mutator transaction binding the contract method 0x6b5f2bff. +// +// Solidity: function upgradeRandomBeacon(address _randomBeacon) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) UpgradeRandomBeacon(_randomBeacon common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpgradeRandomBeacon(&_FrostWalletRegistry.TransactOpts, _randomBeacon) +} + +// UpgradeRandomBeacon is a paid mutator transaction binding the contract method 0x6b5f2bff. +// +// Solidity: function upgradeRandomBeacon(address _randomBeacon) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) UpgradeRandomBeacon(_randomBeacon common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.UpgradeRandomBeacon(&_FrostWalletRegistry.TransactOpts, _randomBeacon) +} + +// WithdrawIneligibleRewards is a paid mutator transaction binding the contract method 0x663032cd. +// +// Solidity: function withdrawIneligibleRewards(address recipient) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) WithdrawIneligibleRewards(opts *bind.TransactOpts, recipient common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "withdrawIneligibleRewards", recipient) +} + +// WithdrawIneligibleRewards is a paid mutator transaction binding the contract method 0x663032cd. +// +// Solidity: function withdrawIneligibleRewards(address recipient) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) WithdrawIneligibleRewards(recipient common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.WithdrawIneligibleRewards(&_FrostWalletRegistry.TransactOpts, recipient) +} + +// WithdrawIneligibleRewards is a paid mutator transaction binding the contract method 0x663032cd. +// +// Solidity: function withdrawIneligibleRewards(address recipient) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) WithdrawIneligibleRewards(recipient common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.WithdrawIneligibleRewards(&_FrostWalletRegistry.TransactOpts, recipient) +} + +// WithdrawRewards is a paid mutator transaction binding the contract method 0x42d86693. +// +// Solidity: function withdrawRewards(address stakingProvider) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactor) WithdrawRewards(opts *bind.TransactOpts, stakingProvider common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.contract.Transact(opts, "withdrawRewards", stakingProvider) +} + +// WithdrawRewards is a paid mutator transaction binding the contract method 0x42d86693. +// +// Solidity: function withdrawRewards(address stakingProvider) returns() +func (_FrostWalletRegistry *FrostWalletRegistrySession) WithdrawRewards(stakingProvider common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.WithdrawRewards(&_FrostWalletRegistry.TransactOpts, stakingProvider) +} + +// WithdrawRewards is a paid mutator transaction binding the contract method 0x42d86693. +// +// Solidity: function withdrawRewards(address stakingProvider) returns() +func (_FrostWalletRegistry *FrostWalletRegistryTransactorSession) WithdrawRewards(stakingProvider common.Address) (*types.Transaction, error) { + return _FrostWalletRegistry.Contract.WithdrawRewards(&_FrostWalletRegistry.TransactOpts, stakingProvider) +} + +// FrostWalletRegistryAuthorizationDecreaseApprovedIterator is returned from FilterAuthorizationDecreaseApproved and is used to iterate over the raw logs and unpacked data for AuthorizationDecreaseApproved events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryAuthorizationDecreaseApprovedIterator struct { + Event *FrostWalletRegistryAuthorizationDecreaseApproved // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryAuthorizationDecreaseApprovedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryAuthorizationDecreaseApproved) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryAuthorizationDecreaseApproved) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryAuthorizationDecreaseApprovedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryAuthorizationDecreaseApprovedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryAuthorizationDecreaseApproved represents a AuthorizationDecreaseApproved event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryAuthorizationDecreaseApproved struct { + StakingProvider common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterAuthorizationDecreaseApproved is a free log retrieval operation binding the contract event 0x50270a522c2fef97b6b7385c2aa4a4518adda681530e0a1fe9f5e840f6f2cd9d. +// +// Solidity: event AuthorizationDecreaseApproved(address indexed stakingProvider) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterAuthorizationDecreaseApproved(opts *bind.FilterOpts, stakingProvider []common.Address) (*FrostWalletRegistryAuthorizationDecreaseApprovedIterator, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "AuthorizationDecreaseApproved", stakingProviderRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryAuthorizationDecreaseApprovedIterator{contract: _FrostWalletRegistry.contract, event: "AuthorizationDecreaseApproved", logs: logs, sub: sub}, nil +} + +// WatchAuthorizationDecreaseApproved is a free log subscription operation binding the contract event 0x50270a522c2fef97b6b7385c2aa4a4518adda681530e0a1fe9f5e840f6f2cd9d. +// +// Solidity: event AuthorizationDecreaseApproved(address indexed stakingProvider) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchAuthorizationDecreaseApproved(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryAuthorizationDecreaseApproved, stakingProvider []common.Address) (event.Subscription, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "AuthorizationDecreaseApproved", stakingProviderRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryAuthorizationDecreaseApproved) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "AuthorizationDecreaseApproved", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseAuthorizationDecreaseApproved is a log parse operation binding the contract event 0x50270a522c2fef97b6b7385c2aa4a4518adda681530e0a1fe9f5e840f6f2cd9d. +// +// Solidity: event AuthorizationDecreaseApproved(address indexed stakingProvider) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseAuthorizationDecreaseApproved(log types.Log) (*FrostWalletRegistryAuthorizationDecreaseApproved, error) { + event := new(FrostWalletRegistryAuthorizationDecreaseApproved) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "AuthorizationDecreaseApproved", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryAuthorizationDecreaseRequestedIterator is returned from FilterAuthorizationDecreaseRequested and is used to iterate over the raw logs and unpacked data for AuthorizationDecreaseRequested events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryAuthorizationDecreaseRequestedIterator struct { + Event *FrostWalletRegistryAuthorizationDecreaseRequested // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryAuthorizationDecreaseRequestedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryAuthorizationDecreaseRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryAuthorizationDecreaseRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryAuthorizationDecreaseRequestedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryAuthorizationDecreaseRequestedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryAuthorizationDecreaseRequested represents a AuthorizationDecreaseRequested event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryAuthorizationDecreaseRequested struct { + StakingProvider common.Address + Operator common.Address + FromAmount *big.Int + ToAmount *big.Int + DecreasingAt uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterAuthorizationDecreaseRequested is a free log retrieval operation binding the contract event 0x545cbf267cef6fe43f11f6219417ab43a0e8e345adbaae5f626d9bc325e8535a. +// +// Solidity: event AuthorizationDecreaseRequested(address indexed stakingProvider, address indexed operator, uint96 fromAmount, uint96 toAmount, uint64 decreasingAt) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterAuthorizationDecreaseRequested(opts *bind.FilterOpts, stakingProvider []common.Address, operator []common.Address) (*FrostWalletRegistryAuthorizationDecreaseRequestedIterator, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "AuthorizationDecreaseRequested", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryAuthorizationDecreaseRequestedIterator{contract: _FrostWalletRegistry.contract, event: "AuthorizationDecreaseRequested", logs: logs, sub: sub}, nil +} + +// WatchAuthorizationDecreaseRequested is a free log subscription operation binding the contract event 0x545cbf267cef6fe43f11f6219417ab43a0e8e345adbaae5f626d9bc325e8535a. +// +// Solidity: event AuthorizationDecreaseRequested(address indexed stakingProvider, address indexed operator, uint96 fromAmount, uint96 toAmount, uint64 decreasingAt) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchAuthorizationDecreaseRequested(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryAuthorizationDecreaseRequested, stakingProvider []common.Address, operator []common.Address) (event.Subscription, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "AuthorizationDecreaseRequested", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryAuthorizationDecreaseRequested) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "AuthorizationDecreaseRequested", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseAuthorizationDecreaseRequested is a log parse operation binding the contract event 0x545cbf267cef6fe43f11f6219417ab43a0e8e345adbaae5f626d9bc325e8535a. +// +// Solidity: event AuthorizationDecreaseRequested(address indexed stakingProvider, address indexed operator, uint96 fromAmount, uint96 toAmount, uint64 decreasingAt) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseAuthorizationDecreaseRequested(log types.Log) (*FrostWalletRegistryAuthorizationDecreaseRequested, error) { + event := new(FrostWalletRegistryAuthorizationDecreaseRequested) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "AuthorizationDecreaseRequested", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryAuthorizationIncreasedIterator is returned from FilterAuthorizationIncreased and is used to iterate over the raw logs and unpacked data for AuthorizationIncreased events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryAuthorizationIncreasedIterator struct { + Event *FrostWalletRegistryAuthorizationIncreased // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryAuthorizationIncreasedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryAuthorizationIncreased) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryAuthorizationIncreased) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryAuthorizationIncreasedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryAuthorizationIncreasedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryAuthorizationIncreased represents a AuthorizationIncreased event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryAuthorizationIncreased struct { + StakingProvider common.Address + Operator common.Address + FromAmount *big.Int + ToAmount *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterAuthorizationIncreased is a free log retrieval operation binding the contract event 0x87f9f9f59204f53d57a89a817c6083a17979cd0531791c91e18551a56e3cfdd7. +// +// Solidity: event AuthorizationIncreased(address indexed stakingProvider, address indexed operator, uint96 fromAmount, uint96 toAmount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterAuthorizationIncreased(opts *bind.FilterOpts, stakingProvider []common.Address, operator []common.Address) (*FrostWalletRegistryAuthorizationIncreasedIterator, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "AuthorizationIncreased", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryAuthorizationIncreasedIterator{contract: _FrostWalletRegistry.contract, event: "AuthorizationIncreased", logs: logs, sub: sub}, nil +} + +// WatchAuthorizationIncreased is a free log subscription operation binding the contract event 0x87f9f9f59204f53d57a89a817c6083a17979cd0531791c91e18551a56e3cfdd7. +// +// Solidity: event AuthorizationIncreased(address indexed stakingProvider, address indexed operator, uint96 fromAmount, uint96 toAmount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchAuthorizationIncreased(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryAuthorizationIncreased, stakingProvider []common.Address, operator []common.Address) (event.Subscription, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "AuthorizationIncreased", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryAuthorizationIncreased) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "AuthorizationIncreased", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseAuthorizationIncreased is a log parse operation binding the contract event 0x87f9f9f59204f53d57a89a817c6083a17979cd0531791c91e18551a56e3cfdd7. +// +// Solidity: event AuthorizationIncreased(address indexed stakingProvider, address indexed operator, uint96 fromAmount, uint96 toAmount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseAuthorizationIncreased(log types.Log) (*FrostWalletRegistryAuthorizationIncreased, error) { + event := new(FrostWalletRegistryAuthorizationIncreased) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "AuthorizationIncreased", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryAuthorizationParametersUpdatedIterator is returned from FilterAuthorizationParametersUpdated and is used to iterate over the raw logs and unpacked data for AuthorizationParametersUpdated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryAuthorizationParametersUpdatedIterator struct { + Event *FrostWalletRegistryAuthorizationParametersUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryAuthorizationParametersUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryAuthorizationParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryAuthorizationParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryAuthorizationParametersUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryAuthorizationParametersUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryAuthorizationParametersUpdated represents a AuthorizationParametersUpdated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryAuthorizationParametersUpdated struct { + MinimumAuthorization *big.Int + AuthorizationDecreaseDelay uint64 + AuthorizationDecreaseChangePeriod uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterAuthorizationParametersUpdated is a free log retrieval operation binding the contract event 0x544b726e42801bb47073854eeedae851903f66fe32a5bd24e626e10b90027b51. +// +// Solidity: event AuthorizationParametersUpdated(uint96 minimumAuthorization, uint64 authorizationDecreaseDelay, uint64 authorizationDecreaseChangePeriod) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterAuthorizationParametersUpdated(opts *bind.FilterOpts) (*FrostWalletRegistryAuthorizationParametersUpdatedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "AuthorizationParametersUpdated") + if err != nil { + return nil, err + } + return &FrostWalletRegistryAuthorizationParametersUpdatedIterator{contract: _FrostWalletRegistry.contract, event: "AuthorizationParametersUpdated", logs: logs, sub: sub}, nil +} + +// WatchAuthorizationParametersUpdated is a free log subscription operation binding the contract event 0x544b726e42801bb47073854eeedae851903f66fe32a5bd24e626e10b90027b51. +// +// Solidity: event AuthorizationParametersUpdated(uint96 minimumAuthorization, uint64 authorizationDecreaseDelay, uint64 authorizationDecreaseChangePeriod) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchAuthorizationParametersUpdated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryAuthorizationParametersUpdated) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "AuthorizationParametersUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryAuthorizationParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "AuthorizationParametersUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseAuthorizationParametersUpdated is a log parse operation binding the contract event 0x544b726e42801bb47073854eeedae851903f66fe32a5bd24e626e10b90027b51. +// +// Solidity: event AuthorizationParametersUpdated(uint96 minimumAuthorization, uint64 authorizationDecreaseDelay, uint64 authorizationDecreaseChangePeriod) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseAuthorizationParametersUpdated(log types.Log) (*FrostWalletRegistryAuthorizationParametersUpdated, error) { + event := new(FrostWalletRegistryAuthorizationParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "AuthorizationParametersUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgMaliciousResultSlashedIterator is returned from FilterDkgMaliciousResultSlashed and is used to iterate over the raw logs and unpacked data for DkgMaliciousResultSlashed events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgMaliciousResultSlashedIterator struct { + Event *FrostWalletRegistryDkgMaliciousResultSlashed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgMaliciousResultSlashedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgMaliciousResultSlashed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgMaliciousResultSlashed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgMaliciousResultSlashedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgMaliciousResultSlashedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgMaliciousResultSlashed represents a DkgMaliciousResultSlashed event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgMaliciousResultSlashed struct { + ResultHash [32]byte + SlashingAmount *big.Int + MaliciousSubmitter common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgMaliciousResultSlashed is a free log retrieval operation binding the contract event 0x88f76c659db78142f88e94db3ca791869495394c6c1b3d412ced9022dc97c9e3. +// +// Solidity: event DkgMaliciousResultSlashed(bytes32 indexed resultHash, uint256 slashingAmount, address maliciousSubmitter) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgMaliciousResultSlashed(opts *bind.FilterOpts, resultHash [][32]byte) (*FrostWalletRegistryDkgMaliciousResultSlashedIterator, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgMaliciousResultSlashed", resultHashRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgMaliciousResultSlashedIterator{contract: _FrostWalletRegistry.contract, event: "DkgMaliciousResultSlashed", logs: logs, sub: sub}, nil +} + +// WatchDkgMaliciousResultSlashed is a free log subscription operation binding the contract event 0x88f76c659db78142f88e94db3ca791869495394c6c1b3d412ced9022dc97c9e3. +// +// Solidity: event DkgMaliciousResultSlashed(bytes32 indexed resultHash, uint256 slashingAmount, address maliciousSubmitter) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgMaliciousResultSlashed(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgMaliciousResultSlashed, resultHash [][32]byte) (event.Subscription, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgMaliciousResultSlashed", resultHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgMaliciousResultSlashed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgMaliciousResultSlashed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgMaliciousResultSlashed is a log parse operation binding the contract event 0x88f76c659db78142f88e94db3ca791869495394c6c1b3d412ced9022dc97c9e3. +// +// Solidity: event DkgMaliciousResultSlashed(bytes32 indexed resultHash, uint256 slashingAmount, address maliciousSubmitter) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgMaliciousResultSlashed(log types.Log) (*FrostWalletRegistryDkgMaliciousResultSlashed, error) { + event := new(FrostWalletRegistryDkgMaliciousResultSlashed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgMaliciousResultSlashed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgMaliciousResultSlashingFailedIterator is returned from FilterDkgMaliciousResultSlashingFailed and is used to iterate over the raw logs and unpacked data for DkgMaliciousResultSlashingFailed events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgMaliciousResultSlashingFailedIterator struct { + Event *FrostWalletRegistryDkgMaliciousResultSlashingFailed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgMaliciousResultSlashingFailedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgMaliciousResultSlashingFailed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgMaliciousResultSlashingFailed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgMaliciousResultSlashingFailedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgMaliciousResultSlashingFailedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgMaliciousResultSlashingFailed represents a DkgMaliciousResultSlashingFailed event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgMaliciousResultSlashingFailed struct { + ResultHash [32]byte + SlashingAmount *big.Int + MaliciousSubmitter common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgMaliciousResultSlashingFailed is a free log retrieval operation binding the contract event 0x14621289a12ab59e0737decc388bba91d929c723defb4682d5d19b9a12ecfecb. +// +// Solidity: event DkgMaliciousResultSlashingFailed(bytes32 indexed resultHash, uint256 slashingAmount, address maliciousSubmitter) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgMaliciousResultSlashingFailed(opts *bind.FilterOpts, resultHash [][32]byte) (*FrostWalletRegistryDkgMaliciousResultSlashingFailedIterator, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgMaliciousResultSlashingFailed", resultHashRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgMaliciousResultSlashingFailedIterator{contract: _FrostWalletRegistry.contract, event: "DkgMaliciousResultSlashingFailed", logs: logs, sub: sub}, nil +} + +// WatchDkgMaliciousResultSlashingFailed is a free log subscription operation binding the contract event 0x14621289a12ab59e0737decc388bba91d929c723defb4682d5d19b9a12ecfecb. +// +// Solidity: event DkgMaliciousResultSlashingFailed(bytes32 indexed resultHash, uint256 slashingAmount, address maliciousSubmitter) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgMaliciousResultSlashingFailed(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgMaliciousResultSlashingFailed, resultHash [][32]byte) (event.Subscription, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgMaliciousResultSlashingFailed", resultHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgMaliciousResultSlashingFailed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgMaliciousResultSlashingFailed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgMaliciousResultSlashingFailed is a log parse operation binding the contract event 0x14621289a12ab59e0737decc388bba91d929c723defb4682d5d19b9a12ecfecb. +// +// Solidity: event DkgMaliciousResultSlashingFailed(bytes32 indexed resultHash, uint256 slashingAmount, address maliciousSubmitter) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgMaliciousResultSlashingFailed(log types.Log) (*FrostWalletRegistryDkgMaliciousResultSlashingFailed, error) { + event := new(FrostWalletRegistryDkgMaliciousResultSlashingFailed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgMaliciousResultSlashingFailed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgParametersUpdatedIterator is returned from FilterDkgParametersUpdated and is used to iterate over the raw logs and unpacked data for DkgParametersUpdated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgParametersUpdatedIterator struct { + Event *FrostWalletRegistryDkgParametersUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgParametersUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgParametersUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgParametersUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgParametersUpdated represents a DkgParametersUpdated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgParametersUpdated struct { + SeedTimeout *big.Int + ResultChallengePeriodLength *big.Int + ResultChallengeExtraGas *big.Int + ResultSubmissionTimeout *big.Int + ResultSubmitterPrecedencePeriodLength *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgParametersUpdated is a free log retrieval operation binding the contract event 0x59ae8ed7b3a7e5f6dde4cff478f0ac0aa652c5edc4f4757b09a778a430b02c56. +// +// Solidity: event DkgParametersUpdated(uint256 seedTimeout, uint256 resultChallengePeriodLength, uint256 resultChallengeExtraGas, uint256 resultSubmissionTimeout, uint256 resultSubmitterPrecedencePeriodLength) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgParametersUpdated(opts *bind.FilterOpts) (*FrostWalletRegistryDkgParametersUpdatedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgParametersUpdated") + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgParametersUpdatedIterator{contract: _FrostWalletRegistry.contract, event: "DkgParametersUpdated", logs: logs, sub: sub}, nil +} + +// WatchDkgParametersUpdated is a free log subscription operation binding the contract event 0x59ae8ed7b3a7e5f6dde4cff478f0ac0aa652c5edc4f4757b09a778a430b02c56. +// +// Solidity: event DkgParametersUpdated(uint256 seedTimeout, uint256 resultChallengePeriodLength, uint256 resultChallengeExtraGas, uint256 resultSubmissionTimeout, uint256 resultSubmitterPrecedencePeriodLength) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgParametersUpdated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgParametersUpdated) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgParametersUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgParametersUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgParametersUpdated is a log parse operation binding the contract event 0x59ae8ed7b3a7e5f6dde4cff478f0ac0aa652c5edc4f4757b09a778a430b02c56. +// +// Solidity: event DkgParametersUpdated(uint256 seedTimeout, uint256 resultChallengePeriodLength, uint256 resultChallengeExtraGas, uint256 resultSubmissionTimeout, uint256 resultSubmitterPrecedencePeriodLength) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgParametersUpdated(log types.Log) (*FrostWalletRegistryDkgParametersUpdated, error) { + event := new(FrostWalletRegistryDkgParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgParametersUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgResultApprovedIterator is returned from FilterDkgResultApproved and is used to iterate over the raw logs and unpacked data for DkgResultApproved events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgResultApprovedIterator struct { + Event *FrostWalletRegistryDkgResultApproved // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgResultApprovedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgResultApproved) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgResultApproved) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgResultApprovedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgResultApprovedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgResultApproved represents a DkgResultApproved event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgResultApproved struct { + ResultHash [32]byte + Approver common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgResultApproved is a free log retrieval operation binding the contract event 0xe6e9d5eba171e82025efb3f3d44fd35905e7283d104284cb9f3bbc5bf1e4276f. +// +// Solidity: event DkgResultApproved(bytes32 indexed resultHash, address indexed approver) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgResultApproved(opts *bind.FilterOpts, resultHash [][32]byte, approver []common.Address) (*FrostWalletRegistryDkgResultApprovedIterator, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + var approverRule []interface{} + for _, approverItem := range approver { + approverRule = append(approverRule, approverItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgResultApproved", resultHashRule, approverRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgResultApprovedIterator{contract: _FrostWalletRegistry.contract, event: "DkgResultApproved", logs: logs, sub: sub}, nil +} + +// WatchDkgResultApproved is a free log subscription operation binding the contract event 0xe6e9d5eba171e82025efb3f3d44fd35905e7283d104284cb9f3bbc5bf1e4276f. +// +// Solidity: event DkgResultApproved(bytes32 indexed resultHash, address indexed approver) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgResultApproved(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgResultApproved, resultHash [][32]byte, approver []common.Address) (event.Subscription, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + var approverRule []interface{} + for _, approverItem := range approver { + approverRule = append(approverRule, approverItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgResultApproved", resultHashRule, approverRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgResultApproved) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgResultApproved", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgResultApproved is a log parse operation binding the contract event 0xe6e9d5eba171e82025efb3f3d44fd35905e7283d104284cb9f3bbc5bf1e4276f. +// +// Solidity: event DkgResultApproved(bytes32 indexed resultHash, address indexed approver) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgResultApproved(log types.Log) (*FrostWalletRegistryDkgResultApproved, error) { + event := new(FrostWalletRegistryDkgResultApproved) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgResultApproved", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgResultChallengedIterator is returned from FilterDkgResultChallenged and is used to iterate over the raw logs and unpacked data for DkgResultChallenged events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgResultChallengedIterator struct { + Event *FrostWalletRegistryDkgResultChallenged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgResultChallengedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgResultChallenged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgResultChallenged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgResultChallengedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgResultChallengedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgResultChallenged represents a DkgResultChallenged event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgResultChallenged struct { + ResultHash [32]byte + Challenger common.Address + Reason string + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgResultChallenged is a free log retrieval operation binding the contract event 0x703feb01415a2995816e8d082fd7aad0eacada1a2f63fdb3226e47f8a0285436. +// +// Solidity: event DkgResultChallenged(bytes32 indexed resultHash, address indexed challenger, string reason) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgResultChallenged(opts *bind.FilterOpts, resultHash [][32]byte, challenger []common.Address) (*FrostWalletRegistryDkgResultChallengedIterator, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + var challengerRule []interface{} + for _, challengerItem := range challenger { + challengerRule = append(challengerRule, challengerItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgResultChallenged", resultHashRule, challengerRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgResultChallengedIterator{contract: _FrostWalletRegistry.contract, event: "DkgResultChallenged", logs: logs, sub: sub}, nil +} + +// WatchDkgResultChallenged is a free log subscription operation binding the contract event 0x703feb01415a2995816e8d082fd7aad0eacada1a2f63fdb3226e47f8a0285436. +// +// Solidity: event DkgResultChallenged(bytes32 indexed resultHash, address indexed challenger, string reason) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgResultChallenged(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgResultChallenged, resultHash [][32]byte, challenger []common.Address) (event.Subscription, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + var challengerRule []interface{} + for _, challengerItem := range challenger { + challengerRule = append(challengerRule, challengerItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgResultChallenged", resultHashRule, challengerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgResultChallenged) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgResultChallenged", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgResultChallenged is a log parse operation binding the contract event 0x703feb01415a2995816e8d082fd7aad0eacada1a2f63fdb3226e47f8a0285436. +// +// Solidity: event DkgResultChallenged(bytes32 indexed resultHash, address indexed challenger, string reason) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgResultChallenged(log types.Log) (*FrostWalletRegistryDkgResultChallenged, error) { + event := new(FrostWalletRegistryDkgResultChallenged) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgResultChallenged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgResultSubmittedIterator is returned from FilterDkgResultSubmitted and is used to iterate over the raw logs and unpacked data for DkgResultSubmitted events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgResultSubmittedIterator struct { + Event *FrostWalletRegistryDkgResultSubmitted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgResultSubmittedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgResultSubmitted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgResultSubmitted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgResultSubmittedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgResultSubmittedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgResultSubmitted represents a DkgResultSubmitted event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgResultSubmitted struct { + ResultHash [32]byte + Seed *big.Int + Result FrostDkgResult + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgResultSubmitted is a free log retrieval operation binding the contract event 0xbfc6cd6291b6741d3ac1631ba81a0288d08265bea4d59d452e8c953e11ec11c6. +// +// Solidity: event DkgResultSubmitted(bytes32 indexed resultHash, uint256 indexed seed, (uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgResultSubmitted(opts *bind.FilterOpts, resultHash [][32]byte, seed []*big.Int) (*FrostWalletRegistryDkgResultSubmittedIterator, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + var seedRule []interface{} + for _, seedItem := range seed { + seedRule = append(seedRule, seedItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgResultSubmitted", resultHashRule, seedRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgResultSubmittedIterator{contract: _FrostWalletRegistry.contract, event: "DkgResultSubmitted", logs: logs, sub: sub}, nil +} + +// WatchDkgResultSubmitted is a free log subscription operation binding the contract event 0xbfc6cd6291b6741d3ac1631ba81a0288d08265bea4d59d452e8c953e11ec11c6. +// +// Solidity: event DkgResultSubmitted(bytes32 indexed resultHash, uint256 indexed seed, (uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgResultSubmitted(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgResultSubmitted, resultHash [][32]byte, seed []*big.Int) (event.Subscription, error) { + + var resultHashRule []interface{} + for _, resultHashItem := range resultHash { + resultHashRule = append(resultHashRule, resultHashItem) + } + var seedRule []interface{} + for _, seedItem := range seed { + seedRule = append(seedRule, seedItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgResultSubmitted", resultHashRule, seedRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgResultSubmitted) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgResultSubmitted", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgResultSubmitted is a log parse operation binding the contract event 0xbfc6cd6291b6741d3ac1631ba81a0288d08265bea4d59d452e8c953e11ec11c6. +// +// Solidity: event DkgResultSubmitted(bytes32 indexed resultHash, uint256 indexed seed, (uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgResultSubmitted(log types.Log) (*FrostWalletRegistryDkgResultSubmitted, error) { + event := new(FrostWalletRegistryDkgResultSubmitted) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgResultSubmitted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgSeedTimedOutIterator is returned from FilterDkgSeedTimedOut and is used to iterate over the raw logs and unpacked data for DkgSeedTimedOut events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgSeedTimedOutIterator struct { + Event *FrostWalletRegistryDkgSeedTimedOut // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgSeedTimedOutIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgSeedTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgSeedTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgSeedTimedOutIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgSeedTimedOutIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgSeedTimedOut represents a DkgSeedTimedOut event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgSeedTimedOut struct { + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgSeedTimedOut is a free log retrieval operation binding the contract event 0x68c52f05452e81639fa06f379aee3178cddee4725521fff886f244c99e868b50. +// +// Solidity: event DkgSeedTimedOut() +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgSeedTimedOut(opts *bind.FilterOpts) (*FrostWalletRegistryDkgSeedTimedOutIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgSeedTimedOut") + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgSeedTimedOutIterator{contract: _FrostWalletRegistry.contract, event: "DkgSeedTimedOut", logs: logs, sub: sub}, nil +} + +// WatchDkgSeedTimedOut is a free log subscription operation binding the contract event 0x68c52f05452e81639fa06f379aee3178cddee4725521fff886f244c99e868b50. +// +// Solidity: event DkgSeedTimedOut() +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgSeedTimedOut(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgSeedTimedOut) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgSeedTimedOut") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgSeedTimedOut) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgSeedTimedOut", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgSeedTimedOut is a log parse operation binding the contract event 0x68c52f05452e81639fa06f379aee3178cddee4725521fff886f244c99e868b50. +// +// Solidity: event DkgSeedTimedOut() +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgSeedTimedOut(log types.Log) (*FrostWalletRegistryDkgSeedTimedOut, error) { + event := new(FrostWalletRegistryDkgSeedTimedOut) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgSeedTimedOut", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgStartedIterator is returned from FilterDkgStarted and is used to iterate over the raw logs and unpacked data for DkgStarted events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgStartedIterator struct { + Event *FrostWalletRegistryDkgStarted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgStartedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgStarted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgStarted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgStartedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgStartedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgStarted represents a DkgStarted event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgStarted struct { + Seed *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgStarted is a free log retrieval operation binding the contract event 0xb2ad26c2940889d79df2ee9c758a8aefa00c5ca90eee119af0e5d795df3b98bb. +// +// Solidity: event DkgStarted(uint256 indexed seed) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgStarted(opts *bind.FilterOpts, seed []*big.Int) (*FrostWalletRegistryDkgStartedIterator, error) { + + var seedRule []interface{} + for _, seedItem := range seed { + seedRule = append(seedRule, seedItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgStarted", seedRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgStartedIterator{contract: _FrostWalletRegistry.contract, event: "DkgStarted", logs: logs, sub: sub}, nil +} + +// WatchDkgStarted is a free log subscription operation binding the contract event 0xb2ad26c2940889d79df2ee9c758a8aefa00c5ca90eee119af0e5d795df3b98bb. +// +// Solidity: event DkgStarted(uint256 indexed seed) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgStarted(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgStarted, seed []*big.Int) (event.Subscription, error) { + + var seedRule []interface{} + for _, seedItem := range seed { + seedRule = append(seedRule, seedItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgStarted", seedRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgStarted) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgStarted", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgStarted is a log parse operation binding the contract event 0xb2ad26c2940889d79df2ee9c758a8aefa00c5ca90eee119af0e5d795df3b98bb. +// +// Solidity: event DkgStarted(uint256 indexed seed) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgStarted(log types.Log) (*FrostWalletRegistryDkgStarted, error) { + event := new(FrostWalletRegistryDkgStarted) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgStarted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgStateLockedIterator is returned from FilterDkgStateLocked and is used to iterate over the raw logs and unpacked data for DkgStateLocked events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgStateLockedIterator struct { + Event *FrostWalletRegistryDkgStateLocked // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgStateLockedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgStateLocked) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgStateLocked) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgStateLockedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgStateLockedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgStateLocked represents a DkgStateLocked event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgStateLocked struct { + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgStateLocked is a free log retrieval operation binding the contract event 0x5c3ed2397d4d21298b2fb5027ac8e2d42e3c9c72bbb55ddb030e2a36a0cdff6b. +// +// Solidity: event DkgStateLocked() +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgStateLocked(opts *bind.FilterOpts) (*FrostWalletRegistryDkgStateLockedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgStateLocked") + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgStateLockedIterator{contract: _FrostWalletRegistry.contract, event: "DkgStateLocked", logs: logs, sub: sub}, nil +} + +// WatchDkgStateLocked is a free log subscription operation binding the contract event 0x5c3ed2397d4d21298b2fb5027ac8e2d42e3c9c72bbb55ddb030e2a36a0cdff6b. +// +// Solidity: event DkgStateLocked() +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgStateLocked(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgStateLocked) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgStateLocked") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgStateLocked) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgStateLocked", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgStateLocked is a log parse operation binding the contract event 0x5c3ed2397d4d21298b2fb5027ac8e2d42e3c9c72bbb55ddb030e2a36a0cdff6b. +// +// Solidity: event DkgStateLocked() +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgStateLocked(log types.Log) (*FrostWalletRegistryDkgStateLocked, error) { + event := new(FrostWalletRegistryDkgStateLocked) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgStateLocked", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryDkgTimedOutIterator is returned from FilterDkgTimedOut and is used to iterate over the raw logs and unpacked data for DkgTimedOut events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgTimedOutIterator struct { + Event *FrostWalletRegistryDkgTimedOut // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryDkgTimedOutIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryDkgTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryDkgTimedOutIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryDkgTimedOutIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryDkgTimedOut represents a DkgTimedOut event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryDkgTimedOut struct { + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDkgTimedOut is a free log retrieval operation binding the contract event 0x2852b3e178dd281713b041c3d90b4815bb55b7ec812931d1e8e8d8bb2ed72d3e. +// +// Solidity: event DkgTimedOut() +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterDkgTimedOut(opts *bind.FilterOpts) (*FrostWalletRegistryDkgTimedOutIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "DkgTimedOut") + if err != nil { + return nil, err + } + return &FrostWalletRegistryDkgTimedOutIterator{contract: _FrostWalletRegistry.contract, event: "DkgTimedOut", logs: logs, sub: sub}, nil +} + +// WatchDkgTimedOut is a free log subscription operation binding the contract event 0x2852b3e178dd281713b041c3d90b4815bb55b7ec812931d1e8e8d8bb2ed72d3e. +// +// Solidity: event DkgTimedOut() +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchDkgTimedOut(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryDkgTimedOut) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "DkgTimedOut") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryDkgTimedOut) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgTimedOut", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDkgTimedOut is a log parse operation binding the contract event 0x2852b3e178dd281713b041c3d90b4815bb55b7ec812931d1e8e8d8bb2ed72d3e. +// +// Solidity: event DkgTimedOut() +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseDkgTimedOut(log types.Log) (*FrostWalletRegistryDkgTimedOut, error) { + event := new(FrostWalletRegistryDkgTimedOut) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "DkgTimedOut", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryGasParametersUpdatedIterator is returned from FilterGasParametersUpdated and is used to iterate over the raw logs and unpacked data for GasParametersUpdated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryGasParametersUpdatedIterator struct { + Event *FrostWalletRegistryGasParametersUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryGasParametersUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryGasParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryGasParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryGasParametersUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryGasParametersUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryGasParametersUpdated represents a GasParametersUpdated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryGasParametersUpdated struct { + DkgResultSubmissionGas *big.Int + DkgResultApprovalGasOffset *big.Int + NotifyOperatorInactivityGasOffset *big.Int + NotifySeedTimeoutGasOffset *big.Int + NotifyDkgTimeoutNegativeGasOffset *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterGasParametersUpdated is a free log retrieval operation binding the contract event 0x8a3e64fa6013a36bccca7362e8826b11ba41e57fb60f55309c0ca48904dad082. +// +// Solidity: event GasParametersUpdated(uint256 dkgResultSubmissionGas, uint256 dkgResultApprovalGasOffset, uint256 notifyOperatorInactivityGasOffset, uint256 notifySeedTimeoutGasOffset, uint256 notifyDkgTimeoutNegativeGasOffset) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterGasParametersUpdated(opts *bind.FilterOpts) (*FrostWalletRegistryGasParametersUpdatedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "GasParametersUpdated") + if err != nil { + return nil, err + } + return &FrostWalletRegistryGasParametersUpdatedIterator{contract: _FrostWalletRegistry.contract, event: "GasParametersUpdated", logs: logs, sub: sub}, nil +} + +// WatchGasParametersUpdated is a free log subscription operation binding the contract event 0x8a3e64fa6013a36bccca7362e8826b11ba41e57fb60f55309c0ca48904dad082. +// +// Solidity: event GasParametersUpdated(uint256 dkgResultSubmissionGas, uint256 dkgResultApprovalGasOffset, uint256 notifyOperatorInactivityGasOffset, uint256 notifySeedTimeoutGasOffset, uint256 notifyDkgTimeoutNegativeGasOffset) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchGasParametersUpdated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryGasParametersUpdated) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "GasParametersUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryGasParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "GasParametersUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseGasParametersUpdated is a log parse operation binding the contract event 0x8a3e64fa6013a36bccca7362e8826b11ba41e57fb60f55309c0ca48904dad082. +// +// Solidity: event GasParametersUpdated(uint256 dkgResultSubmissionGas, uint256 dkgResultApprovalGasOffset, uint256 notifyOperatorInactivityGasOffset, uint256 notifySeedTimeoutGasOffset, uint256 notifyDkgTimeoutNegativeGasOffset) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseGasParametersUpdated(log types.Log) (*FrostWalletRegistryGasParametersUpdated, error) { + event := new(FrostWalletRegistryGasParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "GasParametersUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryGovernanceTransferredIterator is returned from FilterGovernanceTransferred and is used to iterate over the raw logs and unpacked data for GovernanceTransferred events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryGovernanceTransferredIterator struct { + Event *FrostWalletRegistryGovernanceTransferred // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryGovernanceTransferredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryGovernanceTransferred) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryGovernanceTransferred) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryGovernanceTransferredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryGovernanceTransferredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryGovernanceTransferred represents a GovernanceTransferred event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryGovernanceTransferred struct { + OldGovernance common.Address + NewGovernance common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterGovernanceTransferred is a free log retrieval operation binding the contract event 0x5f56bee8cffbe9a78652a74a60705edede02af10b0bbb888ca44b79a0d42ce80. +// +// Solidity: event GovernanceTransferred(address oldGovernance, address newGovernance) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterGovernanceTransferred(opts *bind.FilterOpts) (*FrostWalletRegistryGovernanceTransferredIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "GovernanceTransferred") + if err != nil { + return nil, err + } + return &FrostWalletRegistryGovernanceTransferredIterator{contract: _FrostWalletRegistry.contract, event: "GovernanceTransferred", logs: logs, sub: sub}, nil +} + +// WatchGovernanceTransferred is a free log subscription operation binding the contract event 0x5f56bee8cffbe9a78652a74a60705edede02af10b0bbb888ca44b79a0d42ce80. +// +// Solidity: event GovernanceTransferred(address oldGovernance, address newGovernance) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchGovernanceTransferred(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryGovernanceTransferred) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "GovernanceTransferred") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryGovernanceTransferred) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "GovernanceTransferred", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseGovernanceTransferred is a log parse operation binding the contract event 0x5f56bee8cffbe9a78652a74a60705edede02af10b0bbb888ca44b79a0d42ce80. +// +// Solidity: event GovernanceTransferred(address oldGovernance, address newGovernance) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseGovernanceTransferred(log types.Log) (*FrostWalletRegistryGovernanceTransferred, error) { + event := new(FrostWalletRegistryGovernanceTransferred) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "GovernanceTransferred", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryInactivityClaimedIterator is returned from FilterInactivityClaimed and is used to iterate over the raw logs and unpacked data for InactivityClaimed events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryInactivityClaimedIterator struct { + Event *FrostWalletRegistryInactivityClaimed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryInactivityClaimedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryInactivityClaimed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryInactivityClaimed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryInactivityClaimedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryInactivityClaimedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryInactivityClaimed represents a InactivityClaimed event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryInactivityClaimed struct { + WalletID [32]byte + Nonce *big.Int + Notifier common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterInactivityClaimed is a free log retrieval operation binding the contract event 0x326e1ff7c130ed708307116f79cf7dbca649503e7082e5e35a19ceeee1523b39. +// +// Solidity: event InactivityClaimed(bytes32 indexed walletID, uint256 nonce, address notifier) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterInactivityClaimed(opts *bind.FilterOpts, walletID [][32]byte) (*FrostWalletRegistryInactivityClaimedIterator, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "InactivityClaimed", walletIDRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryInactivityClaimedIterator{contract: _FrostWalletRegistry.contract, event: "InactivityClaimed", logs: logs, sub: sub}, nil +} + +// WatchInactivityClaimed is a free log subscription operation binding the contract event 0x326e1ff7c130ed708307116f79cf7dbca649503e7082e5e35a19ceeee1523b39. +// +// Solidity: event InactivityClaimed(bytes32 indexed walletID, uint256 nonce, address notifier) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchInactivityClaimed(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryInactivityClaimed, walletID [][32]byte) (event.Subscription, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "InactivityClaimed", walletIDRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryInactivityClaimed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "InactivityClaimed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseInactivityClaimed is a log parse operation binding the contract event 0x326e1ff7c130ed708307116f79cf7dbca649503e7082e5e35a19ceeee1523b39. +// +// Solidity: event InactivityClaimed(bytes32 indexed walletID, uint256 nonce, address notifier) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseInactivityClaimed(log types.Log) (*FrostWalletRegistryInactivityClaimed, error) { + event := new(FrostWalletRegistryInactivityClaimed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "InactivityClaimed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryInitializedIterator is returned from FilterInitialized and is used to iterate over the raw logs and unpacked data for Initialized events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryInitializedIterator struct { + Event *FrostWalletRegistryInitialized // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryInitializedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryInitialized) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryInitialized) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryInitializedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryInitializedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryInitialized represents a Initialized event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryInitialized struct { + Version uint8 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterInitialized is a free log retrieval operation binding the contract event 0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498. +// +// Solidity: event Initialized(uint8 version) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterInitialized(opts *bind.FilterOpts) (*FrostWalletRegistryInitializedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "Initialized") + if err != nil { + return nil, err + } + return &FrostWalletRegistryInitializedIterator{contract: _FrostWalletRegistry.contract, event: "Initialized", logs: logs, sub: sub}, nil +} + +// WatchInitialized is a free log subscription operation binding the contract event 0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498. +// +// Solidity: event Initialized(uint8 version) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchInitialized(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryInitialized) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "Initialized") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryInitialized) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "Initialized", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseInitialized is a log parse operation binding the contract event 0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498. +// +// Solidity: event Initialized(uint8 version) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseInitialized(log types.Log) (*FrostWalletRegistryInitialized, error) { + event := new(FrostWalletRegistryInitialized) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "Initialized", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailedIterator is returned from FilterInvoluntaryAuthorizationDecreaseFailed and is used to iterate over the raw logs and unpacked data for InvoluntaryAuthorizationDecreaseFailed events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailedIterator struct { + Event *FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailed represents a InvoluntaryAuthorizationDecreaseFailed event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailed struct { + StakingProvider common.Address + Operator common.Address + FromAmount *big.Int + ToAmount *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterInvoluntaryAuthorizationDecreaseFailed is a free log retrieval operation binding the contract event 0x1b09380d63e78fd72c1d79a805a7e2dfadf02b22418e24bebff51376b7df33b0. +// +// Solidity: event InvoluntaryAuthorizationDecreaseFailed(address indexed stakingProvider, address indexed operator, uint96 fromAmount, uint96 toAmount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterInvoluntaryAuthorizationDecreaseFailed(opts *bind.FilterOpts, stakingProvider []common.Address, operator []common.Address) (*FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailedIterator, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "InvoluntaryAuthorizationDecreaseFailed", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailedIterator{contract: _FrostWalletRegistry.contract, event: "InvoluntaryAuthorizationDecreaseFailed", logs: logs, sub: sub}, nil +} + +// WatchInvoluntaryAuthorizationDecreaseFailed is a free log subscription operation binding the contract event 0x1b09380d63e78fd72c1d79a805a7e2dfadf02b22418e24bebff51376b7df33b0. +// +// Solidity: event InvoluntaryAuthorizationDecreaseFailed(address indexed stakingProvider, address indexed operator, uint96 fromAmount, uint96 toAmount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchInvoluntaryAuthorizationDecreaseFailed(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailed, stakingProvider []common.Address, operator []common.Address) (event.Subscription, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "InvoluntaryAuthorizationDecreaseFailed", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "InvoluntaryAuthorizationDecreaseFailed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseInvoluntaryAuthorizationDecreaseFailed is a log parse operation binding the contract event 0x1b09380d63e78fd72c1d79a805a7e2dfadf02b22418e24bebff51376b7df33b0. +// +// Solidity: event InvoluntaryAuthorizationDecreaseFailed(address indexed stakingProvider, address indexed operator, uint96 fromAmount, uint96 toAmount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseInvoluntaryAuthorizationDecreaseFailed(log types.Log) (*FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailed, error) { + event := new(FrostWalletRegistryInvoluntaryAuthorizationDecreaseFailed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "InvoluntaryAuthorizationDecreaseFailed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryLifecycleOwnerUpdatedIterator is returned from FilterLifecycleOwnerUpdated and is used to iterate over the raw logs and unpacked data for LifecycleOwnerUpdated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryLifecycleOwnerUpdatedIterator struct { + Event *FrostWalletRegistryLifecycleOwnerUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryLifecycleOwnerUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryLifecycleOwnerUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryLifecycleOwnerUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryLifecycleOwnerUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryLifecycleOwnerUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryLifecycleOwnerUpdated represents a LifecycleOwnerUpdated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryLifecycleOwnerUpdated struct { + LifecycleOwner common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterLifecycleOwnerUpdated is a free log retrieval operation binding the contract event 0xc41594e25066d174fb0130f0ddd858b71b9a4f035b2f07d903a4385337c93382. +// +// Solidity: event LifecycleOwnerUpdated(address lifecycleOwner) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterLifecycleOwnerUpdated(opts *bind.FilterOpts) (*FrostWalletRegistryLifecycleOwnerUpdatedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "LifecycleOwnerUpdated") + if err != nil { + return nil, err + } + return &FrostWalletRegistryLifecycleOwnerUpdatedIterator{contract: _FrostWalletRegistry.contract, event: "LifecycleOwnerUpdated", logs: logs, sub: sub}, nil +} + +// WatchLifecycleOwnerUpdated is a free log subscription operation binding the contract event 0xc41594e25066d174fb0130f0ddd858b71b9a4f035b2f07d903a4385337c93382. +// +// Solidity: event LifecycleOwnerUpdated(address lifecycleOwner) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchLifecycleOwnerUpdated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryLifecycleOwnerUpdated) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "LifecycleOwnerUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryLifecycleOwnerUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "LifecycleOwnerUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseLifecycleOwnerUpdated is a log parse operation binding the contract event 0xc41594e25066d174fb0130f0ddd858b71b9a4f035b2f07d903a4385337c93382. +// +// Solidity: event LifecycleOwnerUpdated(address lifecycleOwner) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseLifecycleOwnerUpdated(log types.Log) (*FrostWalletRegistryLifecycleOwnerUpdated, error) { + event := new(FrostWalletRegistryLifecycleOwnerUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "LifecycleOwnerUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryOperatorJoinedSortitionPoolIterator is returned from FilterOperatorJoinedSortitionPool and is used to iterate over the raw logs and unpacked data for OperatorJoinedSortitionPool events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryOperatorJoinedSortitionPoolIterator struct { + Event *FrostWalletRegistryOperatorJoinedSortitionPool // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryOperatorJoinedSortitionPoolIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryOperatorJoinedSortitionPool) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryOperatorJoinedSortitionPool) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryOperatorJoinedSortitionPoolIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryOperatorJoinedSortitionPoolIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryOperatorJoinedSortitionPool represents a OperatorJoinedSortitionPool event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryOperatorJoinedSortitionPool struct { + StakingProvider common.Address + Operator common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOperatorJoinedSortitionPool is a free log retrieval operation binding the contract event 0x5075aaa89894a888eb2cac81a27320c60855febb0cf1706b66bdc754e640d433. +// +// Solidity: event OperatorJoinedSortitionPool(address indexed stakingProvider, address indexed operator) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterOperatorJoinedSortitionPool(opts *bind.FilterOpts, stakingProvider []common.Address, operator []common.Address) (*FrostWalletRegistryOperatorJoinedSortitionPoolIterator, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "OperatorJoinedSortitionPool", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryOperatorJoinedSortitionPoolIterator{contract: _FrostWalletRegistry.contract, event: "OperatorJoinedSortitionPool", logs: logs, sub: sub}, nil +} + +// WatchOperatorJoinedSortitionPool is a free log subscription operation binding the contract event 0x5075aaa89894a888eb2cac81a27320c60855febb0cf1706b66bdc754e640d433. +// +// Solidity: event OperatorJoinedSortitionPool(address indexed stakingProvider, address indexed operator) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchOperatorJoinedSortitionPool(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryOperatorJoinedSortitionPool, stakingProvider []common.Address, operator []common.Address) (event.Subscription, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "OperatorJoinedSortitionPool", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryOperatorJoinedSortitionPool) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "OperatorJoinedSortitionPool", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOperatorJoinedSortitionPool is a log parse operation binding the contract event 0x5075aaa89894a888eb2cac81a27320c60855febb0cf1706b66bdc754e640d433. +// +// Solidity: event OperatorJoinedSortitionPool(address indexed stakingProvider, address indexed operator) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseOperatorJoinedSortitionPool(log types.Log) (*FrostWalletRegistryOperatorJoinedSortitionPool, error) { + event := new(FrostWalletRegistryOperatorJoinedSortitionPool) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "OperatorJoinedSortitionPool", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryOperatorRegisteredIterator is returned from FilterOperatorRegistered and is used to iterate over the raw logs and unpacked data for OperatorRegistered events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryOperatorRegisteredIterator struct { + Event *FrostWalletRegistryOperatorRegistered // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryOperatorRegisteredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryOperatorRegistered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryOperatorRegistered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryOperatorRegisteredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryOperatorRegisteredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryOperatorRegistered represents a OperatorRegistered event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryOperatorRegistered struct { + StakingProvider common.Address + Operator common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOperatorRegistered is a free log retrieval operation binding the contract event 0xa453db612af59e5521d6ab9284dc3e2d06af286eb1b1b7b771fce4716c19f2c1. +// +// Solidity: event OperatorRegistered(address indexed stakingProvider, address indexed operator) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterOperatorRegistered(opts *bind.FilterOpts, stakingProvider []common.Address, operator []common.Address) (*FrostWalletRegistryOperatorRegisteredIterator, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "OperatorRegistered", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryOperatorRegisteredIterator{contract: _FrostWalletRegistry.contract, event: "OperatorRegistered", logs: logs, sub: sub}, nil +} + +// WatchOperatorRegistered is a free log subscription operation binding the contract event 0xa453db612af59e5521d6ab9284dc3e2d06af286eb1b1b7b771fce4716c19f2c1. +// +// Solidity: event OperatorRegistered(address indexed stakingProvider, address indexed operator) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchOperatorRegistered(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryOperatorRegistered, stakingProvider []common.Address, operator []common.Address) (event.Subscription, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "OperatorRegistered", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryOperatorRegistered) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "OperatorRegistered", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOperatorRegistered is a log parse operation binding the contract event 0xa453db612af59e5521d6ab9284dc3e2d06af286eb1b1b7b771fce4716c19f2c1. +// +// Solidity: event OperatorRegistered(address indexed stakingProvider, address indexed operator) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseOperatorRegistered(log types.Log) (*FrostWalletRegistryOperatorRegistered, error) { + event := new(FrostWalletRegistryOperatorRegistered) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "OperatorRegistered", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryOperatorStatusUpdatedIterator is returned from FilterOperatorStatusUpdated and is used to iterate over the raw logs and unpacked data for OperatorStatusUpdated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryOperatorStatusUpdatedIterator struct { + Event *FrostWalletRegistryOperatorStatusUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryOperatorStatusUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryOperatorStatusUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryOperatorStatusUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryOperatorStatusUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryOperatorStatusUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryOperatorStatusUpdated represents a OperatorStatusUpdated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryOperatorStatusUpdated struct { + StakingProvider common.Address + Operator common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOperatorStatusUpdated is a free log retrieval operation binding the contract event 0x1231fe5ee649a593b524a494cd53146a196380a872115a0d0fe16c0735afdf26. +// +// Solidity: event OperatorStatusUpdated(address indexed stakingProvider, address indexed operator) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterOperatorStatusUpdated(opts *bind.FilterOpts, stakingProvider []common.Address, operator []common.Address) (*FrostWalletRegistryOperatorStatusUpdatedIterator, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "OperatorStatusUpdated", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryOperatorStatusUpdatedIterator{contract: _FrostWalletRegistry.contract, event: "OperatorStatusUpdated", logs: logs, sub: sub}, nil +} + +// WatchOperatorStatusUpdated is a free log subscription operation binding the contract event 0x1231fe5ee649a593b524a494cd53146a196380a872115a0d0fe16c0735afdf26. +// +// Solidity: event OperatorStatusUpdated(address indexed stakingProvider, address indexed operator) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchOperatorStatusUpdated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryOperatorStatusUpdated, stakingProvider []common.Address, operator []common.Address) (event.Subscription, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + var operatorRule []interface{} + for _, operatorItem := range operator { + operatorRule = append(operatorRule, operatorItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "OperatorStatusUpdated", stakingProviderRule, operatorRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryOperatorStatusUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "OperatorStatusUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOperatorStatusUpdated is a log parse operation binding the contract event 0x1231fe5ee649a593b524a494cd53146a196380a872115a0d0fe16c0735afdf26. +// +// Solidity: event OperatorStatusUpdated(address indexed stakingProvider, address indexed operator) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseOperatorStatusUpdated(log types.Log) (*FrostWalletRegistryOperatorStatusUpdated, error) { + event := new(FrostWalletRegistryOperatorStatusUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "OperatorStatusUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryRandomBeaconUpgradedIterator is returned from FilterRandomBeaconUpgraded and is used to iterate over the raw logs and unpacked data for RandomBeaconUpgraded events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryRandomBeaconUpgradedIterator struct { + Event *FrostWalletRegistryRandomBeaconUpgraded // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryRandomBeaconUpgradedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryRandomBeaconUpgraded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryRandomBeaconUpgraded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryRandomBeaconUpgradedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryRandomBeaconUpgradedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryRandomBeaconUpgraded represents a RandomBeaconUpgraded event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryRandomBeaconUpgraded struct { + RandomBeacon common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRandomBeaconUpgraded is a free log retrieval operation binding the contract event 0x2b34e21b6daa8fcf8cba1c3ed709cbed2b0231d5fb60e9ccd8c2e75a5674bcb3. +// +// Solidity: event RandomBeaconUpgraded(address randomBeacon) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterRandomBeaconUpgraded(opts *bind.FilterOpts) (*FrostWalletRegistryRandomBeaconUpgradedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "RandomBeaconUpgraded") + if err != nil { + return nil, err + } + return &FrostWalletRegistryRandomBeaconUpgradedIterator{contract: _FrostWalletRegistry.contract, event: "RandomBeaconUpgraded", logs: logs, sub: sub}, nil +} + +// WatchRandomBeaconUpgraded is a free log subscription operation binding the contract event 0x2b34e21b6daa8fcf8cba1c3ed709cbed2b0231d5fb60e9ccd8c2e75a5674bcb3. +// +// Solidity: event RandomBeaconUpgraded(address randomBeacon) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchRandomBeaconUpgraded(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryRandomBeaconUpgraded) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "RandomBeaconUpgraded") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryRandomBeaconUpgraded) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "RandomBeaconUpgraded", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRandomBeaconUpgraded is a log parse operation binding the contract event 0x2b34e21b6daa8fcf8cba1c3ed709cbed2b0231d5fb60e9ccd8c2e75a5674bcb3. +// +// Solidity: event RandomBeaconUpgraded(address randomBeacon) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseRandomBeaconUpgraded(log types.Log) (*FrostWalletRegistryRandomBeaconUpgraded, error) { + event := new(FrostWalletRegistryRandomBeaconUpgraded) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "RandomBeaconUpgraded", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryReimbursementPoolUpdatedIterator is returned from FilterReimbursementPoolUpdated and is used to iterate over the raw logs and unpacked data for ReimbursementPoolUpdated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryReimbursementPoolUpdatedIterator struct { + Event *FrostWalletRegistryReimbursementPoolUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryReimbursementPoolUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryReimbursementPoolUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryReimbursementPoolUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryReimbursementPoolUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryReimbursementPoolUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryReimbursementPoolUpdated represents a ReimbursementPoolUpdated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryReimbursementPoolUpdated struct { + NewReimbursementPool common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReimbursementPoolUpdated is a free log retrieval operation binding the contract event 0x0e2d2343d31b085b7c4e56d1c8a6ec79f7ab07460386f1c9a1756239fe2533ac. +// +// Solidity: event ReimbursementPoolUpdated(address newReimbursementPool) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterReimbursementPoolUpdated(opts *bind.FilterOpts) (*FrostWalletRegistryReimbursementPoolUpdatedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "ReimbursementPoolUpdated") + if err != nil { + return nil, err + } + return &FrostWalletRegistryReimbursementPoolUpdatedIterator{contract: _FrostWalletRegistry.contract, event: "ReimbursementPoolUpdated", logs: logs, sub: sub}, nil +} + +// WatchReimbursementPoolUpdated is a free log subscription operation binding the contract event 0x0e2d2343d31b085b7c4e56d1c8a6ec79f7ab07460386f1c9a1756239fe2533ac. +// +// Solidity: event ReimbursementPoolUpdated(address newReimbursementPool) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchReimbursementPoolUpdated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryReimbursementPoolUpdated) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "ReimbursementPoolUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryReimbursementPoolUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "ReimbursementPoolUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReimbursementPoolUpdated is a log parse operation binding the contract event 0x0e2d2343d31b085b7c4e56d1c8a6ec79f7ab07460386f1c9a1756239fe2533ac. +// +// Solidity: event ReimbursementPoolUpdated(address newReimbursementPool) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseReimbursementPoolUpdated(log types.Log) (*FrostWalletRegistryReimbursementPoolUpdated, error) { + event := new(FrostWalletRegistryReimbursementPoolUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "ReimbursementPoolUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryRewardParametersUpdatedIterator is returned from FilterRewardParametersUpdated and is used to iterate over the raw logs and unpacked data for RewardParametersUpdated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryRewardParametersUpdatedIterator struct { + Event *FrostWalletRegistryRewardParametersUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryRewardParametersUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryRewardParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryRewardParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryRewardParametersUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryRewardParametersUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryRewardParametersUpdated represents a RewardParametersUpdated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryRewardParametersUpdated struct { + MaliciousDkgResultNotificationRewardMultiplier *big.Int + SortitionPoolRewardsBanDuration *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRewardParametersUpdated is a free log retrieval operation binding the contract event 0xf3a6ee10a78fb7d212e87d9be970fb16bd7324e9dc9c38d21cd7ecde781a1d2a. +// +// Solidity: event RewardParametersUpdated(uint256 maliciousDkgResultNotificationRewardMultiplier, uint256 sortitionPoolRewardsBanDuration) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterRewardParametersUpdated(opts *bind.FilterOpts) (*FrostWalletRegistryRewardParametersUpdatedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "RewardParametersUpdated") + if err != nil { + return nil, err + } + return &FrostWalletRegistryRewardParametersUpdatedIterator{contract: _FrostWalletRegistry.contract, event: "RewardParametersUpdated", logs: logs, sub: sub}, nil +} + +// WatchRewardParametersUpdated is a free log subscription operation binding the contract event 0xf3a6ee10a78fb7d212e87d9be970fb16bd7324e9dc9c38d21cd7ecde781a1d2a. +// +// Solidity: event RewardParametersUpdated(uint256 maliciousDkgResultNotificationRewardMultiplier, uint256 sortitionPoolRewardsBanDuration) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchRewardParametersUpdated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryRewardParametersUpdated) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "RewardParametersUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryRewardParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "RewardParametersUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRewardParametersUpdated is a log parse operation binding the contract event 0xf3a6ee10a78fb7d212e87d9be970fb16bd7324e9dc9c38d21cd7ecde781a1d2a. +// +// Solidity: event RewardParametersUpdated(uint256 maliciousDkgResultNotificationRewardMultiplier, uint256 sortitionPoolRewardsBanDuration) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseRewardParametersUpdated(log types.Log) (*FrostWalletRegistryRewardParametersUpdated, error) { + event := new(FrostWalletRegistryRewardParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "RewardParametersUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryRewardsWithdrawnIterator is returned from FilterRewardsWithdrawn and is used to iterate over the raw logs and unpacked data for RewardsWithdrawn events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryRewardsWithdrawnIterator struct { + Event *FrostWalletRegistryRewardsWithdrawn // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryRewardsWithdrawnIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryRewardsWithdrawn) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryRewardsWithdrawn) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryRewardsWithdrawnIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryRewardsWithdrawnIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryRewardsWithdrawn represents a RewardsWithdrawn event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryRewardsWithdrawn struct { + StakingProvider common.Address + Amount *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRewardsWithdrawn is a free log retrieval operation binding the contract event 0x38532b6dea69d7266fa923c7813d190be37625f2454ddfa3d93c45c79482e3fd. +// +// Solidity: event RewardsWithdrawn(address indexed stakingProvider, uint96 amount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterRewardsWithdrawn(opts *bind.FilterOpts, stakingProvider []common.Address) (*FrostWalletRegistryRewardsWithdrawnIterator, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "RewardsWithdrawn", stakingProviderRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryRewardsWithdrawnIterator{contract: _FrostWalletRegistry.contract, event: "RewardsWithdrawn", logs: logs, sub: sub}, nil +} + +// WatchRewardsWithdrawn is a free log subscription operation binding the contract event 0x38532b6dea69d7266fa923c7813d190be37625f2454ddfa3d93c45c79482e3fd. +// +// Solidity: event RewardsWithdrawn(address indexed stakingProvider, uint96 amount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchRewardsWithdrawn(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryRewardsWithdrawn, stakingProvider []common.Address) (event.Subscription, error) { + + var stakingProviderRule []interface{} + for _, stakingProviderItem := range stakingProvider { + stakingProviderRule = append(stakingProviderRule, stakingProviderItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "RewardsWithdrawn", stakingProviderRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryRewardsWithdrawn) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "RewardsWithdrawn", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRewardsWithdrawn is a log parse operation binding the contract event 0x38532b6dea69d7266fa923c7813d190be37625f2454ddfa3d93c45c79482e3fd. +// +// Solidity: event RewardsWithdrawn(address indexed stakingProvider, uint96 amount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseRewardsWithdrawn(log types.Log) (*FrostWalletRegistryRewardsWithdrawn, error) { + event := new(FrostWalletRegistryRewardsWithdrawn) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "RewardsWithdrawn", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistrySlashingParametersUpdatedIterator is returned from FilterSlashingParametersUpdated and is used to iterate over the raw logs and unpacked data for SlashingParametersUpdated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistrySlashingParametersUpdatedIterator struct { + Event *FrostWalletRegistrySlashingParametersUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistrySlashingParametersUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistrySlashingParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistrySlashingParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistrySlashingParametersUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistrySlashingParametersUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistrySlashingParametersUpdated represents a SlashingParametersUpdated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistrySlashingParametersUpdated struct { + MaliciousDkgResultSlashingAmount *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterSlashingParametersUpdated is a free log retrieval operation binding the contract event 0xe132b87eb6644ee4d4c3c32744f7e1c3906335a2d4f99330767bf573909c7d84. +// +// Solidity: event SlashingParametersUpdated(uint256 maliciousDkgResultSlashingAmount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterSlashingParametersUpdated(opts *bind.FilterOpts) (*FrostWalletRegistrySlashingParametersUpdatedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "SlashingParametersUpdated") + if err != nil { + return nil, err + } + return &FrostWalletRegistrySlashingParametersUpdatedIterator{contract: _FrostWalletRegistry.contract, event: "SlashingParametersUpdated", logs: logs, sub: sub}, nil +} + +// WatchSlashingParametersUpdated is a free log subscription operation binding the contract event 0xe132b87eb6644ee4d4c3c32744f7e1c3906335a2d4f99330767bf573909c7d84. +// +// Solidity: event SlashingParametersUpdated(uint256 maliciousDkgResultSlashingAmount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchSlashingParametersUpdated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistrySlashingParametersUpdated) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "SlashingParametersUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistrySlashingParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "SlashingParametersUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseSlashingParametersUpdated is a log parse operation binding the contract event 0xe132b87eb6644ee4d4c3c32744f7e1c3906335a2d4f99330767bf573909c7d84. +// +// Solidity: event SlashingParametersUpdated(uint256 maliciousDkgResultSlashingAmount) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseSlashingParametersUpdated(log types.Log) (*FrostWalletRegistrySlashingParametersUpdated, error) { + event := new(FrostWalletRegistrySlashingParametersUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "SlashingParametersUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryWalletClosedIterator is returned from FilterWalletClosed and is used to iterate over the raw logs and unpacked data for WalletClosed events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryWalletClosedIterator struct { + Event *FrostWalletRegistryWalletClosed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryWalletClosedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryWalletClosed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryWalletClosed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryWalletClosedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryWalletClosedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryWalletClosed represents a WalletClosed event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryWalletClosed struct { + WalletID [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterWalletClosed is a free log retrieval operation binding the contract event 0xa6ae4af610b8ada39d3675190ead27a5552631a8e33f53e4e37dbb082f11a73e. +// +// Solidity: event WalletClosed(bytes32 indexed walletID) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterWalletClosed(opts *bind.FilterOpts, walletID [][32]byte) (*FrostWalletRegistryWalletClosedIterator, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "WalletClosed", walletIDRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryWalletClosedIterator{contract: _FrostWalletRegistry.contract, event: "WalletClosed", logs: logs, sub: sub}, nil +} + +// WatchWalletClosed is a free log subscription operation binding the contract event 0xa6ae4af610b8ada39d3675190ead27a5552631a8e33f53e4e37dbb082f11a73e. +// +// Solidity: event WalletClosed(bytes32 indexed walletID) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchWalletClosed(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryWalletClosed, walletID [][32]byte) (event.Subscription, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "WalletClosed", walletIDRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryWalletClosed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "WalletClosed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseWalletClosed is a log parse operation binding the contract event 0xa6ae4af610b8ada39d3675190ead27a5552631a8e33f53e4e37dbb082f11a73e. +// +// Solidity: event WalletClosed(bytes32 indexed walletID) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseWalletClosed(log types.Log) (*FrostWalletRegistryWalletClosed, error) { + event := new(FrostWalletRegistryWalletClosed) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "WalletClosed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryWalletCreatedIterator is returned from FilterWalletCreated and is used to iterate over the raw logs and unpacked data for WalletCreated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryWalletCreatedIterator struct { + Event *FrostWalletRegistryWalletCreated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryWalletCreatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryWalletCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryWalletCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryWalletCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryWalletCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryWalletCreated represents a WalletCreated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryWalletCreated struct { + WalletID [32]byte + DkgResultHash [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterWalletCreated is a free log retrieval operation binding the contract event 0xbe8f27cef1f3d94120c9c547c3614f5b992fdb0c0a497cc920fde06546291ab4. +// +// Solidity: event WalletCreated(bytes32 indexed walletID, bytes32 indexed dkgResultHash) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterWalletCreated(opts *bind.FilterOpts, walletID [][32]byte, dkgResultHash [][32]byte) (*FrostWalletRegistryWalletCreatedIterator, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + var dkgResultHashRule []interface{} + for _, dkgResultHashItem := range dkgResultHash { + dkgResultHashRule = append(dkgResultHashRule, dkgResultHashItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "WalletCreated", walletIDRule, dkgResultHashRule) + if err != nil { + return nil, err + } + return &FrostWalletRegistryWalletCreatedIterator{contract: _FrostWalletRegistry.contract, event: "WalletCreated", logs: logs, sub: sub}, nil +} + +// WatchWalletCreated is a free log subscription operation binding the contract event 0xbe8f27cef1f3d94120c9c547c3614f5b992fdb0c0a497cc920fde06546291ab4. +// +// Solidity: event WalletCreated(bytes32 indexed walletID, bytes32 indexed dkgResultHash) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchWalletCreated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryWalletCreated, walletID [][32]byte, dkgResultHash [][32]byte) (event.Subscription, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + var dkgResultHashRule []interface{} + for _, dkgResultHashItem := range dkgResultHash { + dkgResultHashRule = append(dkgResultHashRule, dkgResultHashItem) + } + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "WalletCreated", walletIDRule, dkgResultHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryWalletCreated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "WalletCreated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseWalletCreated is a log parse operation binding the contract event 0xbe8f27cef1f3d94120c9c547c3614f5b992fdb0c0a497cc920fde06546291ab4. +// +// Solidity: event WalletCreated(bytes32 indexed walletID, bytes32 indexed dkgResultHash) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseWalletCreated(log types.Log) (*FrostWalletRegistryWalletCreated, error) { + event := new(FrostWalletRegistryWalletCreated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "WalletCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// FrostWalletRegistryWalletOwnerUpdatedIterator is returned from FilterWalletOwnerUpdated and is used to iterate over the raw logs and unpacked data for WalletOwnerUpdated events raised by the FrostWalletRegistry contract. +type FrostWalletRegistryWalletOwnerUpdatedIterator struct { + Event *FrostWalletRegistryWalletOwnerUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *FrostWalletRegistryWalletOwnerUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryWalletOwnerUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(FrostWalletRegistryWalletOwnerUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *FrostWalletRegistryWalletOwnerUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *FrostWalletRegistryWalletOwnerUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// FrostWalletRegistryWalletOwnerUpdated represents a WalletOwnerUpdated event raised by the FrostWalletRegistry contract. +type FrostWalletRegistryWalletOwnerUpdated struct { + WalletOwner common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterWalletOwnerUpdated is a free log retrieval operation binding the contract event 0xa1993af5a189ba5ad4155263c920cfee33ce0593a8eb231a13bb3ce6f39459e3. +// +// Solidity: event WalletOwnerUpdated(address walletOwner) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) FilterWalletOwnerUpdated(opts *bind.FilterOpts) (*FrostWalletRegistryWalletOwnerUpdatedIterator, error) { + + logs, sub, err := _FrostWalletRegistry.contract.FilterLogs(opts, "WalletOwnerUpdated") + if err != nil { + return nil, err + } + return &FrostWalletRegistryWalletOwnerUpdatedIterator{contract: _FrostWalletRegistry.contract, event: "WalletOwnerUpdated", logs: logs, sub: sub}, nil +} + +// WatchWalletOwnerUpdated is a free log subscription operation binding the contract event 0xa1993af5a189ba5ad4155263c920cfee33ce0593a8eb231a13bb3ce6f39459e3. +// +// Solidity: event WalletOwnerUpdated(address walletOwner) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) WatchWalletOwnerUpdated(opts *bind.WatchOpts, sink chan<- *FrostWalletRegistryWalletOwnerUpdated) (event.Subscription, error) { + + logs, sub, err := _FrostWalletRegistry.contract.WatchLogs(opts, "WalletOwnerUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(FrostWalletRegistryWalletOwnerUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "WalletOwnerUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseWalletOwnerUpdated is a log parse operation binding the contract event 0xa1993af5a189ba5ad4155263c920cfee33ce0593a8eb231a13bb3ce6f39459e3. +// +// Solidity: event WalletOwnerUpdated(address walletOwner) +func (_FrostWalletRegistry *FrostWalletRegistryFilterer) ParseWalletOwnerUpdated(log types.Log) (*FrostWalletRegistryWalletOwnerUpdated, error) { + event := new(FrostWalletRegistryWalletOwnerUpdated) + if err := _FrostWalletRegistry.contract.UnpackLog(event, "WalletOwnerUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/chain/ethereum/frost/gen/gen.go b/pkg/chain/ethereum/frost/gen/gen.go new file mode 100644 index 0000000000..50c56d7157 --- /dev/null +++ b/pkg/chain/ethereum/frost/gen/gen.go @@ -0,0 +1,13 @@ +package gen + +var ( + // FrostWalletRegistryAddress is zero for development builds. Operators must + // configure the deployed registry address explicitly until the FROST + // registry artifact is published with network addresses. + FrostWalletRegistryAddress = "0x0000000000000000000000000000000000000000" + + // FrostDkgValidatorAddress is zero for development builds. It is optional + // for runtime challenge checks, which use FrostWalletRegistry.isDkgResultValid, + // but can be configured for pre-submit resultDigest sanity checks. + FrostDkgValidatorAddress = "0x0000000000000000000000000000000000000000" +) diff --git a/pkg/chain/ethereum/frost/gen/validatorabi/FrostDkgValidator.go b/pkg/chain/ethereum/frost/gen/validatorabi/FrostDkgValidator.go new file mode 100644 index 0000000000..f7a1a84792 --- /dev/null +++ b/pkg/chain/ethereum/frost/gen/validatorabi/FrostDkgValidator.go @@ -0,0 +1,598 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package validatorabi + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// FrostDkgResult is an auto generated low-level Go binding around an user-defined struct. +type FrostDkgResult struct { + SubmitterMemberIndex *big.Int + XOnlyOutputKey [32]byte + MisbehavedMembersIndices []uint8 + Signatures []byte + SigningMembersIndices []*big.Int + Members []uint32 + MembersHash [32]byte +} + +// FrostDkgValidatorDigestBinding is an auto generated low-level Go binding around an user-defined struct. +type FrostDkgValidatorDigestBinding struct { + Bridge common.Address + Registry common.Address +} + +// FrostDkgValidatorMetaData contains all meta data concerning the FrostDkgValidator contract. +var FrostDkgValidatorMetaData = &bind.MetaData{ + ABI: "[{\"inputs\":[{\"internalType\":\"contractSortitionPool\",\"name\":\"_sortitionPool\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"activeThreshold\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"groupSize\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"groupThreshold\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"publicKeyByteSize\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"result\",\"type\":\"tuple\"},{\"internalType\":\"uint256\",\"name\":\"seed\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"bridge\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"registry\",\"type\":\"address\"}],\"name\":\"resultDigest\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"digest\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"signatureByteSize\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"sortitionPool\",\"outputs\":[{\"internalType\":\"contractSortitionPool\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"result\",\"type\":\"tuple\"},{\"internalType\":\"uint256\",\"name\":\"seed\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"startBlock\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"bridge\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"registry\",\"type\":\"address\"}],\"name\":\"validate\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"isValid\",\"type\":\"bool\"},{\"internalType\":\"string\",\"name\":\"errorMsg\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"result\",\"type\":\"tuple\"}],\"name\":\"validateFields\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"isValid\",\"type\":\"bool\"},{\"internalType\":\"string\",\"name\":\"errorMsg\",\"type\":\"string\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"result\",\"type\":\"tuple\"},{\"internalType\":\"uint256\",\"name\":\"seed\",\"type\":\"uint256\"}],\"name\":\"validateGroupMembers\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"result\",\"type\":\"tuple\"}],\"name\":\"validateMembersHash\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"submitterMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"},{\"internalType\":\"uint8[]\",\"name\":\"misbehavedMembersIndices\",\"type\":\"uint8[]\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"uint256[]\",\"name\":\"signingMembersIndices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint32[]\",\"name\":\"members\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes32\",\"name\":\"membersHash\",\"type\":\"bytes32\"}],\"internalType\":\"structFrostDkg.Result\",\"name\":\"result\",\"type\":\"tuple\"},{\"internalType\":\"uint256\",\"name\":\"seed\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"},{\"components\":[{\"internalType\":\"address\",\"name\":\"bridge\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"registry\",\"type\":\"address\"}],\"internalType\":\"structFrostDkgValidator.DigestBinding\",\"name\":\"binding\",\"type\":\"tuple\"}],\"name\":\"validateSignatures\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", +} + +// FrostDkgValidatorABI is the input ABI used to generate the binding from. +// Deprecated: Use FrostDkgValidatorMetaData.ABI instead. +var FrostDkgValidatorABI = FrostDkgValidatorMetaData.ABI + +// FrostDkgValidator is an auto generated Go binding around an Ethereum contract. +type FrostDkgValidator struct { + FrostDkgValidatorCaller // Read-only binding to the contract + FrostDkgValidatorTransactor // Write-only binding to the contract + FrostDkgValidatorFilterer // Log filterer for contract events +} + +// FrostDkgValidatorCaller is an auto generated read-only Go binding around an Ethereum contract. +type FrostDkgValidatorCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// FrostDkgValidatorTransactor is an auto generated write-only Go binding around an Ethereum contract. +type FrostDkgValidatorTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// FrostDkgValidatorFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type FrostDkgValidatorFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// FrostDkgValidatorSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type FrostDkgValidatorSession struct { + Contract *FrostDkgValidator // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// FrostDkgValidatorCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type FrostDkgValidatorCallerSession struct { + Contract *FrostDkgValidatorCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// FrostDkgValidatorTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type FrostDkgValidatorTransactorSession struct { + Contract *FrostDkgValidatorTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// FrostDkgValidatorRaw is an auto generated low-level Go binding around an Ethereum contract. +type FrostDkgValidatorRaw struct { + Contract *FrostDkgValidator // Generic contract binding to access the raw methods on +} + +// FrostDkgValidatorCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type FrostDkgValidatorCallerRaw struct { + Contract *FrostDkgValidatorCaller // Generic read-only contract binding to access the raw methods on +} + +// FrostDkgValidatorTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type FrostDkgValidatorTransactorRaw struct { + Contract *FrostDkgValidatorTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewFrostDkgValidator creates a new instance of FrostDkgValidator, bound to a specific deployed contract. +func NewFrostDkgValidator(address common.Address, backend bind.ContractBackend) (*FrostDkgValidator, error) { + contract, err := bindFrostDkgValidator(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &FrostDkgValidator{FrostDkgValidatorCaller: FrostDkgValidatorCaller{contract: contract}, FrostDkgValidatorTransactor: FrostDkgValidatorTransactor{contract: contract}, FrostDkgValidatorFilterer: FrostDkgValidatorFilterer{contract: contract}}, nil +} + +// NewFrostDkgValidatorCaller creates a new read-only instance of FrostDkgValidator, bound to a specific deployed contract. +func NewFrostDkgValidatorCaller(address common.Address, caller bind.ContractCaller) (*FrostDkgValidatorCaller, error) { + contract, err := bindFrostDkgValidator(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &FrostDkgValidatorCaller{contract: contract}, nil +} + +// NewFrostDkgValidatorTransactor creates a new write-only instance of FrostDkgValidator, bound to a specific deployed contract. +func NewFrostDkgValidatorTransactor(address common.Address, transactor bind.ContractTransactor) (*FrostDkgValidatorTransactor, error) { + contract, err := bindFrostDkgValidator(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &FrostDkgValidatorTransactor{contract: contract}, nil +} + +// NewFrostDkgValidatorFilterer creates a new log filterer instance of FrostDkgValidator, bound to a specific deployed contract. +func NewFrostDkgValidatorFilterer(address common.Address, filterer bind.ContractFilterer) (*FrostDkgValidatorFilterer, error) { + contract, err := bindFrostDkgValidator(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &FrostDkgValidatorFilterer{contract: contract}, nil +} + +// bindFrostDkgValidator binds a generic wrapper to an already deployed contract. +func bindFrostDkgValidator(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := FrostDkgValidatorMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_FrostDkgValidator *FrostDkgValidatorRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _FrostDkgValidator.Contract.FrostDkgValidatorCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_FrostDkgValidator *FrostDkgValidatorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _FrostDkgValidator.Contract.FrostDkgValidatorTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_FrostDkgValidator *FrostDkgValidatorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _FrostDkgValidator.Contract.FrostDkgValidatorTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_FrostDkgValidator *FrostDkgValidatorCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _FrostDkgValidator.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_FrostDkgValidator *FrostDkgValidatorTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _FrostDkgValidator.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_FrostDkgValidator *FrostDkgValidatorTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _FrostDkgValidator.Contract.contract.Transact(opts, method, params...) +} + +// ActiveThreshold is a free data retrieval call binding the contract method 0x281efe71. +// +// Solidity: function activeThreshold() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCaller) ActiveThreshold(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "activeThreshold") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// ActiveThreshold is a free data retrieval call binding the contract method 0x281efe71. +// +// Solidity: function activeThreshold() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorSession) ActiveThreshold() (*big.Int, error) { + return _FrostDkgValidator.Contract.ActiveThreshold(&_FrostDkgValidator.CallOpts) +} + +// ActiveThreshold is a free data retrieval call binding the contract method 0x281efe71. +// +// Solidity: function activeThreshold() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) ActiveThreshold() (*big.Int, error) { + return _FrostDkgValidator.Contract.ActiveThreshold(&_FrostDkgValidator.CallOpts) +} + +// GroupSize is a free data retrieval call binding the contract method 0x63b635ea. +// +// Solidity: function groupSize() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCaller) GroupSize(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "groupSize") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GroupSize is a free data retrieval call binding the contract method 0x63b635ea. +// +// Solidity: function groupSize() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorSession) GroupSize() (*big.Int, error) { + return _FrostDkgValidator.Contract.GroupSize(&_FrostDkgValidator.CallOpts) +} + +// GroupSize is a free data retrieval call binding the contract method 0x63b635ea. +// +// Solidity: function groupSize() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) GroupSize() (*big.Int, error) { + return _FrostDkgValidator.Contract.GroupSize(&_FrostDkgValidator.CallOpts) +} + +// GroupThreshold is a free data retrieval call binding the contract method 0x6dcc64f8. +// +// Solidity: function groupThreshold() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCaller) GroupThreshold(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "groupThreshold") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GroupThreshold is a free data retrieval call binding the contract method 0x6dcc64f8. +// +// Solidity: function groupThreshold() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorSession) GroupThreshold() (*big.Int, error) { + return _FrostDkgValidator.Contract.GroupThreshold(&_FrostDkgValidator.CallOpts) +} + +// GroupThreshold is a free data retrieval call binding the contract method 0x6dcc64f8. +// +// Solidity: function groupThreshold() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) GroupThreshold() (*big.Int, error) { + return _FrostDkgValidator.Contract.GroupThreshold(&_FrostDkgValidator.CallOpts) +} + +// PublicKeyByteSize is a free data retrieval call binding the contract method 0x05f8ae15. +// +// Solidity: function publicKeyByteSize() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCaller) PublicKeyByteSize(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "publicKeyByteSize") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// PublicKeyByteSize is a free data retrieval call binding the contract method 0x05f8ae15. +// +// Solidity: function publicKeyByteSize() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorSession) PublicKeyByteSize() (*big.Int, error) { + return _FrostDkgValidator.Contract.PublicKeyByteSize(&_FrostDkgValidator.CallOpts) +} + +// PublicKeyByteSize is a free data retrieval call binding the contract method 0x05f8ae15. +// +// Solidity: function publicKeyByteSize() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) PublicKeyByteSize() (*big.Int, error) { + return _FrostDkgValidator.Contract.PublicKeyByteSize(&_FrostDkgValidator.CallOpts) +} + +// ResultDigest is a free data retrieval call binding the contract method 0xa63415cd. +// +// Solidity: function resultDigest((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed, address bridge, address registry) view returns(bytes32 digest) +func (_FrostDkgValidator *FrostDkgValidatorCaller) ResultDigest(opts *bind.CallOpts, result FrostDkgResult, seed *big.Int, bridge common.Address, registry common.Address) ([32]byte, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "resultDigest", result, seed, bridge, registry) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// ResultDigest is a free data retrieval call binding the contract method 0xa63415cd. +// +// Solidity: function resultDigest((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed, address bridge, address registry) view returns(bytes32 digest) +func (_FrostDkgValidator *FrostDkgValidatorSession) ResultDigest(result FrostDkgResult, seed *big.Int, bridge common.Address, registry common.Address) ([32]byte, error) { + return _FrostDkgValidator.Contract.ResultDigest(&_FrostDkgValidator.CallOpts, result, seed, bridge, registry) +} + +// ResultDigest is a free data retrieval call binding the contract method 0xa63415cd. +// +// Solidity: function resultDigest((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed, address bridge, address registry) view returns(bytes32 digest) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) ResultDigest(result FrostDkgResult, seed *big.Int, bridge common.Address, registry common.Address) ([32]byte, error) { + return _FrostDkgValidator.Contract.ResultDigest(&_FrostDkgValidator.CallOpts, result, seed, bridge, registry) +} + +// SignatureByteSize is a free data retrieval call binding the contract method 0x89ef44b0. +// +// Solidity: function signatureByteSize() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCaller) SignatureByteSize(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "signatureByteSize") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// SignatureByteSize is a free data retrieval call binding the contract method 0x89ef44b0. +// +// Solidity: function signatureByteSize() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorSession) SignatureByteSize() (*big.Int, error) { + return _FrostDkgValidator.Contract.SignatureByteSize(&_FrostDkgValidator.CallOpts) +} + +// SignatureByteSize is a free data retrieval call binding the contract method 0x89ef44b0. +// +// Solidity: function signatureByteSize() view returns(uint256) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) SignatureByteSize() (*big.Int, error) { + return _FrostDkgValidator.Contract.SignatureByteSize(&_FrostDkgValidator.CallOpts) +} + +// SortitionPool is a free data retrieval call binding the contract method 0xb54a2374. +// +// Solidity: function sortitionPool() view returns(address) +func (_FrostDkgValidator *FrostDkgValidatorCaller) SortitionPool(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "sortitionPool") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// SortitionPool is a free data retrieval call binding the contract method 0xb54a2374. +// +// Solidity: function sortitionPool() view returns(address) +func (_FrostDkgValidator *FrostDkgValidatorSession) SortitionPool() (common.Address, error) { + return _FrostDkgValidator.Contract.SortitionPool(&_FrostDkgValidator.CallOpts) +} + +// SortitionPool is a free data retrieval call binding the contract method 0xb54a2374. +// +// Solidity: function sortitionPool() view returns(address) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) SortitionPool() (common.Address, error) { + return _FrostDkgValidator.Contract.SortitionPool(&_FrostDkgValidator.CallOpts) +} + +// Validate is a free data retrieval call binding the contract method 0x8a399fcf. +// +// Solidity: function validate((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed, uint256 startBlock, address bridge, address registry) view returns(bool isValid, string errorMsg) +func (_FrostDkgValidator *FrostDkgValidatorCaller) Validate(opts *bind.CallOpts, result FrostDkgResult, seed *big.Int, startBlock *big.Int, bridge common.Address, registry common.Address) (struct { + IsValid bool + ErrorMsg string +}, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "validate", result, seed, startBlock, bridge, registry) + + outstruct := new(struct { + IsValid bool + ErrorMsg string + }) + if err != nil { + return *outstruct, err + } + + outstruct.IsValid = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.ErrorMsg = *abi.ConvertType(out[1], new(string)).(*string) + + return *outstruct, err + +} + +// Validate is a free data retrieval call binding the contract method 0x8a399fcf. +// +// Solidity: function validate((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed, uint256 startBlock, address bridge, address registry) view returns(bool isValid, string errorMsg) +func (_FrostDkgValidator *FrostDkgValidatorSession) Validate(result FrostDkgResult, seed *big.Int, startBlock *big.Int, bridge common.Address, registry common.Address) (struct { + IsValid bool + ErrorMsg string +}, error) { + return _FrostDkgValidator.Contract.Validate(&_FrostDkgValidator.CallOpts, result, seed, startBlock, bridge, registry) +} + +// Validate is a free data retrieval call binding the contract method 0x8a399fcf. +// +// Solidity: function validate((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed, uint256 startBlock, address bridge, address registry) view returns(bool isValid, string errorMsg) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) Validate(result FrostDkgResult, seed *big.Int, startBlock *big.Int, bridge common.Address, registry common.Address) (struct { + IsValid bool + ErrorMsg string +}, error) { + return _FrostDkgValidator.Contract.Validate(&_FrostDkgValidator.CallOpts, result, seed, startBlock, bridge, registry) +} + +// ValidateFields is a free data retrieval call binding the contract method 0x0a51bd1f. +// +// Solidity: function validateFields((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) pure returns(bool isValid, string errorMsg) +func (_FrostDkgValidator *FrostDkgValidatorCaller) ValidateFields(opts *bind.CallOpts, result FrostDkgResult) (struct { + IsValid bool + ErrorMsg string +}, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "validateFields", result) + + outstruct := new(struct { + IsValid bool + ErrorMsg string + }) + if err != nil { + return *outstruct, err + } + + outstruct.IsValid = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.ErrorMsg = *abi.ConvertType(out[1], new(string)).(*string) + + return *outstruct, err + +} + +// ValidateFields is a free data retrieval call binding the contract method 0x0a51bd1f. +// +// Solidity: function validateFields((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) pure returns(bool isValid, string errorMsg) +func (_FrostDkgValidator *FrostDkgValidatorSession) ValidateFields(result FrostDkgResult) (struct { + IsValid bool + ErrorMsg string +}, error) { + return _FrostDkgValidator.Contract.ValidateFields(&_FrostDkgValidator.CallOpts, result) +} + +// ValidateFields is a free data retrieval call binding the contract method 0x0a51bd1f. +// +// Solidity: function validateFields((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) pure returns(bool isValid, string errorMsg) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) ValidateFields(result FrostDkgResult) (struct { + IsValid bool + ErrorMsg string +}, error) { + return _FrostDkgValidator.Contract.ValidateFields(&_FrostDkgValidator.CallOpts, result) +} + +// ValidateGroupMembers is a free data retrieval call binding the contract method 0x11ee7310. +// +// Solidity: function validateGroupMembers((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed) view returns(bool) +func (_FrostDkgValidator *FrostDkgValidatorCaller) ValidateGroupMembers(opts *bind.CallOpts, result FrostDkgResult, seed *big.Int) (bool, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "validateGroupMembers", result, seed) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// ValidateGroupMembers is a free data retrieval call binding the contract method 0x11ee7310. +// +// Solidity: function validateGroupMembers((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed) view returns(bool) +func (_FrostDkgValidator *FrostDkgValidatorSession) ValidateGroupMembers(result FrostDkgResult, seed *big.Int) (bool, error) { + return _FrostDkgValidator.Contract.ValidateGroupMembers(&_FrostDkgValidator.CallOpts, result, seed) +} + +// ValidateGroupMembers is a free data retrieval call binding the contract method 0x11ee7310. +// +// Solidity: function validateGroupMembers((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed) view returns(bool) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) ValidateGroupMembers(result FrostDkgResult, seed *big.Int) (bool, error) { + return _FrostDkgValidator.Contract.ValidateGroupMembers(&_FrostDkgValidator.CallOpts, result, seed) +} + +// ValidateMembersHash is a free data retrieval call binding the contract method 0xd01d1f3f. +// +// Solidity: function validateMembersHash((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) pure returns(bool) +func (_FrostDkgValidator *FrostDkgValidatorCaller) ValidateMembersHash(opts *bind.CallOpts, result FrostDkgResult) (bool, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "validateMembersHash", result) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// ValidateMembersHash is a free data retrieval call binding the contract method 0xd01d1f3f. +// +// Solidity: function validateMembersHash((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) pure returns(bool) +func (_FrostDkgValidator *FrostDkgValidatorSession) ValidateMembersHash(result FrostDkgResult) (bool, error) { + return _FrostDkgValidator.Contract.ValidateMembersHash(&_FrostDkgValidator.CallOpts, result) +} + +// ValidateMembersHash is a free data retrieval call binding the contract method 0xd01d1f3f. +// +// Solidity: function validateMembersHash((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result) pure returns(bool) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) ValidateMembersHash(result FrostDkgResult) (bool, error) { + return _FrostDkgValidator.Contract.ValidateMembersHash(&_FrostDkgValidator.CallOpts, result) +} + +// ValidateSignatures is a free data retrieval call binding the contract method 0xb03a9444. +// +// Solidity: function validateSignatures((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed, uint256 , (address,address) binding) view returns(bool) +func (_FrostDkgValidator *FrostDkgValidatorCaller) ValidateSignatures(opts *bind.CallOpts, result FrostDkgResult, seed *big.Int, arg2 *big.Int, binding FrostDkgValidatorDigestBinding) (bool, error) { + var out []interface{} + err := _FrostDkgValidator.contract.Call(opts, &out, "validateSignatures", result, seed, arg2, binding) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// ValidateSignatures is a free data retrieval call binding the contract method 0xb03a9444. +// +// Solidity: function validateSignatures((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed, uint256 , (address,address) binding) view returns(bool) +func (_FrostDkgValidator *FrostDkgValidatorSession) ValidateSignatures(result FrostDkgResult, seed *big.Int, arg2 *big.Int, binding FrostDkgValidatorDigestBinding) (bool, error) { + return _FrostDkgValidator.Contract.ValidateSignatures(&_FrostDkgValidator.CallOpts, result, seed, arg2, binding) +} + +// ValidateSignatures is a free data retrieval call binding the contract method 0xb03a9444. +// +// Solidity: function validateSignatures((uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32) result, uint256 seed, uint256 , (address,address) binding) view returns(bool) +func (_FrostDkgValidator *FrostDkgValidatorCallerSession) ValidateSignatures(result FrostDkgResult, seed *big.Int, arg2 *big.Int, binding FrostDkgValidatorDigestBinding) (bool, error) { + return _FrostDkgValidator.Contract.ValidateSignatures(&_FrostDkgValidator.CallOpts, result, seed, arg2, binding) +} diff --git a/pkg/chain/ethereum/frost_bindings_test.go b/pkg/chain/ethereum/frost_bindings_test.go new file mode 100644 index 0000000000..f1f613a60f --- /dev/null +++ b/pkg/chain/ethereum/frost_bindings_test.go @@ -0,0 +1,124 @@ +package ethereum + +import ( + "bytes" + "reflect" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" + frostvalidatorabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/validatorabi" +) + +const frostDkgResultTupleSignature = "(uint256,bytes32,uint8[],bytes,uint256[],uint32[],bytes32)" + +func TestFrostGeneratedBindingsUseDeployedDkgResultTupleOrder(t *testing.T) { + expectedFields := []string{ + "SubmitterMemberIndex", + "XOnlyOutputKey", + "MisbehavedMembersIndices", + "Signatures", + "SigningMembersIndices", + "Members", + "MembersHash", + } + + assertStructFieldOrder(t, reflect.TypeOf(frostabi.FrostDkgResult{}), expectedFields) + assertStructFieldOrder( + t, + reflect.TypeOf(frostvalidatorabi.FrostDkgResult{}), + expectedFields, + ) + + walletRegistryABI, err := frostabi.FrostWalletRegistryMetaData.GetAbi() + if err != nil { + t.Fatal(err) + } + + for _, method := range []string{ + "submitDkgResult", + "approveDkgResult", + "challengeDkgResult", + "isDkgResultValid", + } { + expectedSelector := functionSelector( + method + "(" + frostDkgResultTupleSignature + ")", + ) + actualSelector := walletRegistryABI.Methods[method].ID + if !bytes.Equal(actualSelector, expectedSelector) { + t.Fatalf( + "unexpected %s selector: got 0x%x, want 0x%x", + method, + actualSelector, + expectedSelector, + ) + } + } + + expectedEventID := crypto.Keccak256Hash([]byte( + "DkgResultSubmitted(bytes32,uint256," + + frostDkgResultTupleSignature + + ")", + )) + actualEventID := walletRegistryABI.Events["DkgResultSubmitted"].ID + if actualEventID != expectedEventID { + t.Fatalf( + "unexpected DkgResultSubmitted topic: got 0x%x, want 0x%x", + actualEventID, + expectedEventID, + ) + } + + validatorABI, err := frostvalidatorabi.FrostDkgValidatorMetaData.GetAbi() + if err != nil { + t.Fatal(err) + } + + expectedSelector := functionSelector( + "resultDigest(" + + frostDkgResultTupleSignature + + ",uint256,address,address)", + ) + actualSelector := validatorABI.Methods["resultDigest"].ID + if !bytes.Equal(actualSelector, expectedSelector) { + t.Fatalf( + "unexpected resultDigest selector: got 0x%x, want 0x%x", + actualSelector, + expectedSelector, + ) + } +} + +func assertStructFieldOrder( + t *testing.T, + structType reflect.Type, + expectedFields []string, +) { + t.Helper() + + if structType.NumField() != len(expectedFields) { + t.Fatalf( + "unexpected field count for %s: got %d, want %d", + structType.Name(), + structType.NumField(), + len(expectedFields), + ) + } + + for i, expectedField := range expectedFields { + if actualField := structType.Field(i).Name; actualField != expectedField { + t.Fatalf( + "unexpected field %d for %s: got %s, want %s", + i, + structType.Name(), + actualField, + expectedField, + ) + } + } +} + +func functionSelector(signature string) []byte { + hash := crypto.Keccak256([]byte(signature)) + return hash[:4] +} diff --git a/pkg/chain/ethereum/frost_dkg.go b/pkg/chain/ethereum/frost_dkg.go new file mode 100644 index 0000000000..a766bd0f30 --- /dev/null +++ b/pkg/chain/ethereum/frost_dkg.go @@ -0,0 +1,961 @@ +package ethereum + +import ( + "context" + "fmt" + "math/big" + "sort" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" + chainutil "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "github.com/keep-network/keep-core/pkg/chain" + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" + frostvalidatorabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/validatorabi" + "github.com/keep-network/keep-core/pkg/frost" + frostregistry "github.com/keep-network/keep-core/pkg/frost/registry" + "github.com/keep-network/keep-core/pkg/subscription" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +var _ tbtc.FrostDKGChain = (*TbtcChain)(nil) + +// FrostWalletRegistryAvailable reports whether the chain handle is configured +// with a FROST wallet registry address. +func (tc *TbtcChain) FrostWalletRegistryAvailable() bool { + return tc.frostWalletRegistry != nil +} + +// GetFrostOperatorID returns the FROST sortition pool ID number of the given +// operator address. An ID number of 0 means the operator has not been allocated +// an ID number yet. +func (tc *TbtcChain) GetFrostOperatorID( + operatorAddress chain.Address, +) (chain.OperatorID, error) { + if tc.frostSortitionPool == nil { + return 0, fmt.Errorf("FROST sortition pool is not configured") + } + + return getOperatorID( + tc.frostSortitionPool, + common.HexToAddress(operatorAddress.String()), + ) +} + +// OnBridgeNewWalletRequested registers a callback for Bridge.NewWalletRequested. +func (tc *TbtcChain) OnBridgeNewWalletRequested( + handler func(event *tbtc.BridgeNewWalletRequestedEvent), +) subscription.EventSubscription { + return tc.bridge.NewWalletRequestedEvent(nil).OnEvent( + func(blockNumber uint64) { + handler(&tbtc.BridgeNewWalletRequestedEvent{ + BlockNumber: blockNumber, + }) + }, + ) +} + +// OnFrostDKGStarted registers a callback for FrostWalletRegistry.DkgStarted. +func (tc *TbtcChain) OnFrostDKGStarted( + handler func(event *tbtc.FrostDKGStartedEvent), +) subscription.EventSubscription { + if tc.frostWalletRegistry == nil { + return subscription.NewEventSubscription(func() {}) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + events := make(chan *tbtc.FrostDKGStartedEvent) + watchSink := make(chan *frostabi.FrostWalletRegistryDkgStarted) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + handler(event) + } + } + }() + + go func() { + for { + select { + case <-ctx.Done(): + return + case event, ok := <-watchSink: + if !ok { + return + } + emitFrostDKGStartedEvent( + ctx, + events, + &tbtc.FrostDKGStartedEvent{ + Seed: event.Seed, + BlockNumber: event.Raw.BlockNumber, + }, + ) + } + } + }() + + go tc.monitorPastFrostDKGStartedEvents(ctx, events) + + sub := tc.watchFrostDKGStarted(watchSink, nil) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +// PastFrostDKGStartedEvents fetches past FROST DKG started events. +func (tc *TbtcChain) PastFrostDKGStartedEvents( + filter *tbtc.FrostDKGStartedEventFilter, +) ([]*tbtc.FrostDKGStartedEvent, error) { + if tc.frostWalletRegistry == nil { + return nil, fmt.Errorf("FrostWalletRegistry is not configured") + } + + var startBlock uint64 + var endBlock *uint64 + var seed []*big.Int + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + seed = filter.Seed + } + + iterator, err := tc.frostWalletRegistry.FilterDkgStarted( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + seed, + ) + if err != nil { + return nil, err + } + defer iterator.Close() + + events := make([]*tbtc.FrostDKGStartedEvent, 0) + for iterator.Next() { + events = append(events, &tbtc.FrostDKGStartedEvent{ + Seed: iterator.Event.Seed, + BlockNumber: iterator.Event.Raw.BlockNumber, + }) + } + if err := iterator.Error(); err != nil { + return nil, err + } + + sort.SliceStable(events, func(i, j int) bool { + return events[i].BlockNumber < events[j].BlockNumber + }) + + return events, nil +} + +// OnFrostDKGResultSubmitted registers a callback for FROST DKG submissions. +func (tc *TbtcChain) OnFrostDKGResultSubmitted( + handler func(event *tbtc.FrostDKGResultSubmittedEvent), +) subscription.EventSubscription { + if tc.frostWalletRegistry == nil { + return subscription.NewEventSubscription(func() {}) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + events := make(chan *tbtc.FrostDKGResultSubmittedEvent) + watchSink := make(chan *frostabi.FrostWalletRegistryDkgResultSubmitted) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + handler(event) + } + } + }() + + go func() { + for { + select { + case <-ctx.Done(): + return + case event, ok := <-watchSink: + if !ok { + return + } + result, err := convertFrostDKGResultFromABI(event.Result) + if err != nil { + logger.Errorf("unexpected FROST DKG result in event: [%v]", err) + continue + } + + emitFrostDKGResultSubmittedEvent( + ctx, + events, + &tbtc.FrostDKGResultSubmittedEvent{ + Seed: event.Seed, + ResultHash: event.ResultHash, + Result: result, + BlockNumber: event.Raw.BlockNumber, + }, + ) + } + } + }() + + go tc.monitorPastFrostDKGResultSubmittedEvents(ctx, events) + + sub := tc.watchFrostDKGResultSubmitted(watchSink, nil, nil) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +// PastFrostDKGResultSubmittedEvents fetches past FROST DKG submitted events. +func (tc *TbtcChain) PastFrostDKGResultSubmittedEvents( + filter *tbtc.FrostDKGResultSubmittedEventFilter, +) ([]*tbtc.FrostDKGResultSubmittedEvent, error) { + if tc.frostWalletRegistry == nil { + return nil, fmt.Errorf("FrostWalletRegistry is not configured") + } + + var startBlock uint64 + var endBlock *uint64 + var resultHash [][32]byte + var seed []*big.Int + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + for _, hash := range filter.ResultHash { + resultHash = append(resultHash, [32]byte(hash)) + } + seed = filter.Seed + } + + iterator, err := tc.frostWalletRegistry.FilterDkgResultSubmitted( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + resultHash, + seed, + ) + if err != nil { + return nil, err + } + defer iterator.Close() + + events := make([]*tbtc.FrostDKGResultSubmittedEvent, 0) + for iterator.Next() { + result, err := convertFrostDKGResultFromABI(iterator.Event.Result) + if err != nil { + return nil, err + } + + events = append(events, &tbtc.FrostDKGResultSubmittedEvent{ + Seed: iterator.Event.Seed, + ResultHash: iterator.Event.ResultHash, + Result: result, + BlockNumber: iterator.Event.Raw.BlockNumber, + }) + } + if err := iterator.Error(); err != nil { + return nil, err + } + + sort.SliceStable(events, func(i, j int) bool { + return events[i].BlockNumber < events[j].BlockNumber + }) + + return events, nil +} + +func (tc *TbtcChain) watchFrostDKGStarted( + sink chan<- *frostabi.FrostWalletRegistryDkgStarted, + seed []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return tc.frostWalletRegistry.WatchDkgStarted( + &bind.WatchOpts{Context: ctx}, + sink, + seed, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + logger.Warnf( + "subscription to FROST DkgStarted had to be retried [%s] "+ + "since the last attempt; please inspect host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + logger.Errorf( + "subscription to FROST DkgStarted failed with error: [%v]; "+ + "resubscription attempt will be performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (tc *TbtcChain) watchFrostDKGResultSubmitted( + sink chan<- *frostabi.FrostWalletRegistryDkgResultSubmitted, + resultHash [][32]byte, + seed []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return tc.frostWalletRegistry.WatchDkgResultSubmitted( + &bind.WatchOpts{Context: ctx}, + sink, + resultHash, + seed, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + logger.Warnf( + "subscription to FROST DkgResultSubmitted had to be retried [%s] "+ + "since the last attempt; please inspect host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + logger.Errorf( + "subscription to FROST DkgResultSubmitted failed with error: [%v]; "+ + "resubscription attempt will be performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (tc *TbtcChain) monitorPastFrostDKGStartedEvents( + ctx context.Context, + events chan<- *tbtc.FrostDKGStartedEvent, +) { + ticker := time.NewTicker(chainutil.DefaultSubscribeOptsTick) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := tc.blockCounter.CurrentBlock() + if err != nil { + logger.Errorf( + "FROST DkgStarted subscription failed to pull events: [%v]", + err, + ) + continue + } + + fromBlock := frostSubscriptionMonitoringStartBlock(lastBlock) + logger.Infof( + "FROST DkgStarted subscription monitoring fetching past "+ + "events starting from block [%v]", + fromBlock, + ) + + pastEvents, err := tc.PastFrostDKGStartedEvents( + &tbtc.FrostDKGStartedEventFilter{StartBlock: fromBlock}, + ) + if err != nil { + logger.Errorf( + "FROST DkgStarted subscription failed to pull events: [%v]", + err, + ) + continue + } + + logger.Infof( + "FROST DkgStarted subscription monitoring fetched [%v] past events", + len(pastEvents), + ) + + for _, event := range pastEvents { + emitFrostDKGStartedEvent(ctx, events, event) + } + } + } +} + +func (tc *TbtcChain) monitorPastFrostDKGResultSubmittedEvents( + ctx context.Context, + events chan<- *tbtc.FrostDKGResultSubmittedEvent, +) { + ticker := time.NewTicker(chainutil.DefaultSubscribeOptsTick) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := tc.blockCounter.CurrentBlock() + if err != nil { + logger.Errorf( + "FROST DkgResultSubmitted subscription failed to pull events: [%v]", + err, + ) + continue + } + + fromBlock := frostSubscriptionMonitoringStartBlock(lastBlock) + logger.Infof( + "FROST DkgResultSubmitted subscription monitoring fetching past "+ + "events starting from block [%v]", + fromBlock, + ) + + pastEvents, err := tc.PastFrostDKGResultSubmittedEvents( + &tbtc.FrostDKGResultSubmittedEventFilter{StartBlock: fromBlock}, + ) + if err != nil { + logger.Errorf( + "FROST DkgResultSubmitted subscription failed to pull events: [%v]", + err, + ) + continue + } + + logger.Infof( + "FROST DkgResultSubmitted subscription monitoring fetched [%v] past events", + len(pastEvents), + ) + + for _, event := range pastEvents { + emitFrostDKGResultSubmittedEvent(ctx, events, event) + } + } + } +} + +func frostSubscriptionMonitoringStartBlock(lastBlock uint64) uint64 { + pastBlocks := uint64(chainutil.DefaultSubscribeOptsPastBlocks) + if lastBlock <= pastBlocks { + return 0 + } + + return lastBlock - pastBlocks +} + +func emitFrostDKGStartedEvent( + ctx context.Context, + events chan<- *tbtc.FrostDKGStartedEvent, + event *tbtc.FrostDKGStartedEvent, +) { + select { + case <-ctx.Done(): + case events <- event: + } +} + +func emitFrostDKGResultSubmittedEvent( + ctx context.Context, + events chan<- *tbtc.FrostDKGResultSubmittedEvent, + event *tbtc.FrostDKGResultSubmittedEvent, +) { + select { + case <-ctx.Done(): + case events <- event: + } +} + +// OnFrostDKGResultChallenged registers a callback for FROST DKG challenges. +func (tc *TbtcChain) OnFrostDKGResultChallenged( + handler func(event *tbtc.FrostDKGResultChallengedEvent), +) subscription.EventSubscription { + if tc.frostWalletRegistry == nil { + return subscription.NewEventSubscription(func() {}) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + sink := make(chan *frostabi.FrostWalletRegistryDkgResultChallenged) + + sub, err := tc.frostWalletRegistry.WatchDkgResultChallenged( + &bind.WatchOpts{Context: ctx}, + sink, + nil, + nil, + ) + if err != nil { + cancelCtx() + logger.Errorf("failed to watch FROST DKG challenged events: [%v]", err) + return subscription.NewEventSubscription(func() {}) + } + + go func() { + for { + select { + case <-ctx.Done(): + return + case err, ok := <-sub.Err(): + if !ok { + return + } + logger.Errorf("FROST DKG challenged subscription error: [%v]", err) + case event, ok := <-sink: + if !ok { + return + } + handler(&tbtc.FrostDKGResultChallengedEvent{ + ResultHash: event.ResultHash, + Challenger: chain.Address(event.Challenger.String()), + Reason: event.Reason, + BlockNumber: event.Raw.BlockNumber, + }) + } + } + }() + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +// OnFrostDKGResultApproved registers a callback for FROST DKG approvals. +func (tc *TbtcChain) OnFrostDKGResultApproved( + handler func(event *tbtc.FrostDKGResultApprovedEvent), +) subscription.EventSubscription { + if tc.frostWalletRegistry == nil { + return subscription.NewEventSubscription(func() {}) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + sink := make(chan *frostabi.FrostWalletRegistryDkgResultApproved) + + sub, err := tc.frostWalletRegistry.WatchDkgResultApproved( + &bind.WatchOpts{Context: ctx}, + sink, + nil, + nil, + ) + if err != nil { + cancelCtx() + logger.Errorf("failed to watch FROST DKG approved events: [%v]", err) + return subscription.NewEventSubscription(func() {}) + } + + go func() { + for { + select { + case <-ctx.Done(): + return + case err, ok := <-sub.Err(): + if !ok { + return + } + logger.Errorf("FROST DKG approved subscription error: [%v]", err) + case event, ok := <-sink: + if !ok { + return + } + handler(&tbtc.FrostDKGResultApprovedEvent{ + ResultHash: event.ResultHash, + Approver: chain.Address(event.Approver.String()), + BlockNumber: event.Raw.BlockNumber, + }) + } + } + }() + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +// SelectFrostGroup returns the currently selected FROST DKG group. +func (tc *TbtcChain) SelectFrostGroup() (*tbtc.GroupSelectionResult, error) { + if tc.frostWalletRegistry == nil || tc.frostSortitionPool == nil { + return nil, fmt.Errorf("FrostWalletRegistry is not configured") + } + + operatorsIDs, err := tc.frostWalletRegistry.SelectGroup( + &bind.CallOpts{From: tc.key.Address}, + ) + if err != nil { + return nil, err + } + + operatorsAddresses, err := tc.frostSortitionPool.GetIDOperators(operatorsIDs) + if err != nil { + return nil, err + } + + ids := make([]chain.OperatorID, len(operatorsIDs)) + addresses := make([]chain.Address, len(operatorsIDs)) + for i := range ids { + ids[i] = operatorsIDs[i] + addresses[i] = chain.Address(operatorsAddresses[i].String()) + } + + return &tbtc.GroupSelectionResult{ + OperatorsIDs: ids, + OperatorsAddresses: addresses, + }, nil +} + +// GetFrostDKGState returns the current FROST wallet creation state. +func (tc *TbtcChain) GetFrostDKGState() (tbtc.DKGState, error) { + if tc.frostWalletRegistry == nil { + return 0, fmt.Errorf("FrostWalletRegistry is not configured") + } + + state, err := tc.frostWalletRegistry.GetWalletCreationState( + &bind.CallOpts{From: tc.key.Address}, + ) + if err != nil { + return 0, err + } + + return tbtc.DKGState(state), nil +} + +// IsFrostDKGResultValid validates the submitted FROST DKG result using the +// registry-level view. This intentionally avoids passing seed/startBlock from +// off-chain code. +func (tc *TbtcChain) IsFrostDKGResultValid( + result *frostregistry.Result, +) (bool, string, error) { + if tc.frostWalletRegistry == nil { + return false, "", fmt.Errorf("FrostWalletRegistry is not configured") + } + + abiResult, err := convertFrostDKGResultToABI(result) + if err != nil { + return false, "", err + } + + return tc.frostWalletRegistry.IsDkgResultValid( + &bind.CallOpts{From: tc.key.Address}, + abiResult, + ) +} + +// CalculateFrostDKGResultDigest computes the pre-EIP-191 result digest and, +// when the validator view is configured, checks it against the on-chain +// FrostDkgValidator.resultDigest implementation. +func (tc *TbtcChain) CalculateFrostDKGResultDigest( + seed *big.Int, + result *frostregistry.Result, +) ([32]byte, error) { + if result == nil { + return [32]byte{}, fmt.Errorf("FROST DKG result is nil") + } + if tc.frostWalletRegistry == nil { + return [32]byte{}, fmt.Errorf("FrostWalletRegistry is not configured") + } + + localDigest, err := frostregistry.ResultDigest( + tc.chainID, + tc.bridgeAddress, + tc.frostWalletRegistryAddr, + seed, + result.XOnlyOutputKey, + result.Members, + result.MisbehavedMembersIndices, + ) + if err != nil { + return [32]byte{}, err + } + + if tc.frostDkgValidator == nil { + return localDigest, nil + } + + validatorResult, err := convertFrostDKGResultToValidatorABI(result) + if err != nil { + return [32]byte{}, err + } + + onChainDigest, err := tc.frostDkgValidator.ResultDigest( + &bind.CallOpts{From: tc.key.Address}, + validatorResult, + seed, + tc.bridgeAddress, + tc.frostWalletRegistryAddr, + ) + if err != nil { + return [32]byte{}, err + } + + if localDigest != onChainDigest { + return [32]byte{}, fmt.Errorf( + "local FROST DKG digest [0x%x] does not match validator digest [0x%x]", + localDigest, + onChainDigest, + ) + } + + return localDigest, nil +} + +// SubmitFrostDKGResult submits a FROST DKG result. Submission is optimistic on +// chain; callers should pre-validate before invoking this method. +func (tc *TbtcChain) SubmitFrostDKGResult(result *frostregistry.Result) error { + if tc.frostWalletRegistry == nil { + return fmt.Errorf("FrostWalletRegistry is not configured") + } + + abiResult, err := convertFrostDKGResultToABI(result) + if err != nil { + return err + } + + return tc.submitFrostWalletRegistryTransaction( + "submitDkgResult", + func(opts *bind.TransactOpts) (*types.Transaction, error) { + return tc.frostWalletRegistry.SubmitDkgResult(opts, abiResult) + }, + ) +} + +// ChallengeFrostDKGResult challenges an invalid FROST DKG result. The on-chain +// function requires msg.sender == tx.origin, which this EOA chain handle +// satisfies directly. +func (tc *TbtcChain) ChallengeFrostDKGResult(result *frostregistry.Result) error { + if tc.frostWalletRegistry == nil { + return fmt.Errorf("FrostWalletRegistry is not configured") + } + + abiResult, err := convertFrostDKGResultToABI(result) + if err != nil { + return err + } + + return tc.submitFrostWalletRegistryTransaction( + "challengeDkgResult", + func(opts *bind.TransactOpts) (*types.Transaction, error) { + return tc.frostWalletRegistry.ChallengeDkgResult(opts, abiResult) + }, + ) +} + +// ApproveFrostDKGResult approves a FROST DKG result after the challenge window. +// The contract gates submitter precedence using submitterPrecedencePeriodLength. +func (tc *TbtcChain) ApproveFrostDKGResult(result *frostregistry.Result) error { + if tc.frostWalletRegistry == nil { + return fmt.Errorf("FrostWalletRegistry is not configured") + } + + abiResult, err := convertFrostDKGResultToABI(result) + if err != nil { + return err + } + + return tc.submitFrostWalletRegistryTransaction( + "approveDkgResult", + func(opts *bind.TransactOpts) (*types.Transaction, error) { + return tc.frostWalletRegistry.ApproveDkgResult(opts, abiResult) + }, + ) +} + +// FrostDKGParameters gets the current FROST DKG timing parameters. +func (tc *TbtcChain) FrostDKGParameters() (*tbtc.DKGParameters, error) { + if tc.frostWalletRegistry == nil { + return nil, fmt.Errorf("FrostWalletRegistry is not configured") + } + + params, err := tc.frostWalletRegistry.DkgParameters( + &bind.CallOpts{From: tc.key.Address}, + ) + if err != nil { + return nil, err + } + + return &tbtc.DKGParameters{ + SubmissionTimeoutBlocks: params.ResultSubmissionTimeout.Uint64(), + ChallengePeriodBlocks: params.ResultChallengePeriodLength.Uint64(), + ApprovePrecedencePeriodBlocks: params.SubmitterPrecedencePeriodLength.Uint64(), + }, nil +} + +func convertFrostDKGResultFromABI( + result frostabi.FrostDkgResult, +) (*frostregistry.Result, error) { + submitterMemberIndex, err := uint256ToUint64( + result.SubmitterMemberIndex, + "submitter member index", + ) + if err != nil { + return nil, err + } + + signingMembersIndices := make([]uint64, len(result.SigningMembersIndices)) + for i, signingMemberIndex := range result.SigningMembersIndices { + signingMembersIndices[i], err = uint256ToUint64( + signingMemberIndex, + "signing member index", + ) + if err != nil { + return nil, err + } + } + + outputKey := frost.OutputKey(result.XOnlyOutputKey) + + return &frostregistry.Result{ + SubmitterMemberIndex: submitterMemberIndex, + XOnlyOutputKey: outputKey, + MembersHash: result.MembersHash, + MisbehavedMembersIndices: append(frostregistry.MisbehavedMemberIndices{}, result.MisbehavedMembersIndices...), + Signatures: append([]byte{}, result.Signatures...), + SigningMembersIndices: signingMembersIndices, + Members: append(frostregistry.FullMembers{}, result.Members...), + }, nil +} + +func convertFrostDKGResultToABI( + result *frostregistry.Result, +) (frostabi.FrostDkgResult, error) { + if result == nil { + return frostabi.FrostDkgResult{}, fmt.Errorf("FROST DKG result is nil") + } + + signingMembersIndices := make([]*big.Int, len(result.SigningMembersIndices)) + for i, signingMemberIndex := range result.SigningMembersIndices { + signingMembersIndices[i] = new(big.Int).SetUint64(signingMemberIndex) + } + + return frostabi.FrostDkgResult{ + SubmitterMemberIndex: new(big.Int).SetUint64(result.SubmitterMemberIndex), + XOnlyOutputKey: [32]byte(result.XOnlyOutputKey), + MisbehavedMembersIndices: append([]uint8{}, result.MisbehavedMembersIndices...), + Signatures: append([]byte{}, result.Signatures...), + SigningMembersIndices: signingMembersIndices, + Members: append([]uint32{}, result.Members...), + MembersHash: result.MembersHash, + }, nil +} + +func convertFrostDKGResultToValidatorABI( + result *frostregistry.Result, +) (frostvalidatorabi.FrostDkgResult, error) { + if result == nil { + return frostvalidatorabi.FrostDkgResult{}, fmt.Errorf("FROST DKG result is nil") + } + + signingMembersIndices := make([]*big.Int, len(result.SigningMembersIndices)) + for i, signingMemberIndex := range result.SigningMembersIndices { + signingMembersIndices[i] = new(big.Int).SetUint64(signingMemberIndex) + } + + return frostvalidatorabi.FrostDkgResult{ + SubmitterMemberIndex: new(big.Int).SetUint64(result.SubmitterMemberIndex), + XOnlyOutputKey: [32]byte(result.XOnlyOutputKey), + MisbehavedMembersIndices: append([]uint8{}, result.MisbehavedMembersIndices...), + Signatures: append([]byte{}, result.Signatures...), + SigningMembersIndices: signingMembersIndices, + Members: append([]uint32{}, result.Members...), + MembersHash: result.MembersHash, + }, nil +} + +func (tc *TbtcChain) submitFrostWalletRegistryTransaction( + method string, + submitFn func(opts *bind.TransactOpts) (*types.Transaction, error), +) error { + tc.transactionMutex.Lock() + defer tc.transactionMutex.Unlock() + + transactorOptions, err := bind.NewKeyedTransactorWithChainID( + tc.key.PrivateKey, + tc.chainID, + ) + if err != nil { + return fmt.Errorf("failed to instantiate transactor: [%v]", err) + } + + nonce, err := tc.nonceManager.CurrentNonce() + if err != nil { + return fmt.Errorf("failed to retrieve account nonce: [%v]", err) + } + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := submitFn(transactorOptions) + if err != nil { + return fmt.Errorf("failed to submit %s transaction: [%w]", method, err) + } + + logger.Infof( + "submitted transaction %s with id: [%s] and nonce [%v]", + method, + transaction.Hash(), + transaction.Nonce(), + ) + + go tc.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + transaction, err := submitFn(newTransactorOptions) + if err != nil { + return nil, err + } + + logger.Infof( + "submitted transaction %s with id: [%s] and nonce [%v]", + method, + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + tc.nonceManager.IncrementNonce() + + return nil +} + +func uint256ToUint64(value *big.Int, fieldName string) (uint64, error) { + if value == nil { + return 0, fmt.Errorf("%s is nil", fieldName) + } + + if !value.IsUint64() { + return 0, fmt.Errorf("%s [%s] overflows uint64", fieldName, value.String()) + } + + return value.Uint64(), nil +} diff --git a/pkg/chain/ethereum/frost_dkg_test.go b/pkg/chain/ethereum/frost_dkg_test.go new file mode 100644 index 0000000000..51fef57385 --- /dev/null +++ b/pkg/chain/ethereum/frost_dkg_test.go @@ -0,0 +1,21 @@ +package ethereum + +import ( + "testing" + + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" +) + +func TestTbtcChainFrostWalletRegistryAvailable(t *testing.T) { + chainWithoutFrostRegistry := &TbtcChain{} + if chainWithoutFrostRegistry.FrostWalletRegistryAvailable() { + t.Fatal("expected FROST wallet registry to be unavailable") + } + + chainWithFrostRegistry := &TbtcChain{ + frostWalletRegistry: &frostabi.FrostWalletRegistry{}, + } + if !chainWithFrostRegistry.FrostWalletRegistryAvailable() { + t.Fatal("expected FROST wallet registry to be available") + } +} diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 275c68ff0a..5d1a42ce5f 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -10,12 +10,15 @@ import ( "math/big" "reflect" "sort" + "strings" "time" "github.com/keep-network/keep-common/pkg/cache" "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -24,6 +27,8 @@ import ( "github.com/keep-network/keep-core/pkg/chain" ecdsaabi "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen/abi" ecdsacontract "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen/contract" + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" + frostvalidatorabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/validatorabi" tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" tbtccontract "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/contract" "github.com/keep-network/keep-core/pkg/internal/byteutils" @@ -40,6 +45,8 @@ const ( // TODO: The WalletRegistry address is taken from the Bridge contract. // Remove the possibility of passing it through the config. WalletRegistryContractName = "WalletRegistry" + FrostWalletRegistryContractName = "FrostWalletRegistry" + FrostDkgValidatorContractName = "FrostDkgValidator" BridgeContractName = "Bridge" MaintainerProxyContractName = "MaintainerProxy" WalletProposalValidatorContractName = "WalletProposalValidator" @@ -54,14 +61,40 @@ const ( sweptDepositsCachePeriod = 7 * 24 * time.Hour ) +const frostWalletRegistryAuthorizationViewsABI = `[ + { + "inputs": [{"internalType": "address", "name": "operator", "type": "address"}], + "name": "operatorToStakingProvider", + "outputs": [{"internalType": "address", "name": "", "type": "address"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{"internalType": "address", "name": "stakingProvider", "type": "address"}], + "name": "eligibleStake", + "outputs": [{"internalType": "uint96", "name": "", "type": "uint96"}], + "stateMutability": "view", + "type": "function" + } +]` + +var frostWalletRegistryAuthorizationABI = mustParseABI( + frostWalletRegistryAuthorizationViewsABI, +) + // TbtcChain represents a TBTC-specific chain handle. type TbtcChain struct { *baseChain bridge *tbtccontract.Bridge + bridgeAddress common.Address maintainerProxy *tbtccontract.MaintainerProxy walletRegistry *ecdsacontract.WalletRegistry sortitionPool *ecdsacontract.EcdsaSortitionPool + frostWalletRegistry *frostabi.FrostWalletRegistry + frostWalletRegistryAddr common.Address + frostDkgValidator *frostvalidatorabi.FrostDkgValidator + frostSortitionPool *ecdsacontract.EcdsaSortitionPool walletProposalValidator *tbtccontract.WalletProposalValidator redemptionWatchtower *tbtccontract.RedemptionWatchtower // ecdsaDkgValidatorAddress optional; when zero, TBTC uses defaultGroupParameters(network). @@ -184,6 +217,19 @@ func newTbtcChain( ) } + frostWalletRegistry, frostWalletRegistryAddr, frostSortitionPool, err := connectFrostWalletRegistry( + config, + baseChain, + ) + if err != nil { + return nil, err + } + + frostDkgValidator, err := connectFrostDkgValidator(config, baseChain) + if err != nil { + return nil, err + } + walletProposalValidatorAddress, err := config.ContractAddress( WalletProposalValidatorContractName, ) @@ -269,9 +315,14 @@ func newTbtcChain( return &TbtcChain{ baseChain: baseChain, bridge: bridge, + bridgeAddress: bridgeAddress, maintainerProxy: maintainerProxy, walletRegistry: walletRegistry, sortitionPool: sortitionPool, + frostWalletRegistry: frostWalletRegistry, + frostWalletRegistryAddr: frostWalletRegistryAddr, + frostDkgValidator: frostDkgValidator, + frostSortitionPool: frostSortitionPool, walletProposalValidator: walletProposalValidator, redemptionWatchtower: redemptionWatchtower, ecdsaDkgValidatorAddress: ecdsaDkgValidatorAddress, @@ -279,6 +330,119 @@ func newTbtcChain( }, nil } +func connectFrostWalletRegistry( + config ethereum.Config, + baseChain *baseChain, +) ( + *frostabi.FrostWalletRegistry, + common.Address, + *ecdsacontract.EcdsaSortitionPool, + error, +) { + frostWalletRegistryAddress, err := config.ContractAddress( + FrostWalletRegistryContractName, + ) + if err != nil { + return nil, common.Address{}, nil, fmt.Errorf( + "failed to resolve %s contract address: [%v]", + FrostWalletRegistryContractName, + err, + ) + } + + if frostWalletRegistryAddress == (common.Address{}) { + logger.Infof( + "%s contract address not configured; FROST DKG coordinator disabled", + FrostWalletRegistryContractName, + ) + return nil, common.Address{}, nil, nil + } + + frostWalletRegistry, err := frostabi.NewFrostWalletRegistry( + frostWalletRegistryAddress, + baseChain.client, + ) + if err != nil { + return nil, common.Address{}, nil, fmt.Errorf( + "failed to attach to FrostWalletRegistry contract: [%v]", + err, + ) + } + + frostSortitionPoolAddress, err := frostWalletRegistry.SortitionPool( + &bind.CallOpts{From: baseChain.key.Address}, + ) + if err != nil { + return nil, common.Address{}, nil, fmt.Errorf( + "failed to get FROST sortition pool address: [%v]", + err, + ) + } + + // The FROST deployment uses a dedicated sortition pool instance but the + // SortitionPool ABI is the same shape as the ECDSA pool binding. + frostSortitionPool, err := ecdsacontract.NewEcdsaSortitionPool( + frostSortitionPoolAddress, + baseChain.chainID, + baseChain.key, + baseChain.client, + baseChain.nonceManager, + baseChain.miningWaiter, + baseChain.blockCounter, + baseChain.transactionMutex, + ) + if err != nil { + return nil, common.Address{}, nil, fmt.Errorf( + "failed to attach to FrostSortitionPool contract: [%v]", + err, + ) + } + + return frostWalletRegistry, frostWalletRegistryAddress, frostSortitionPool, nil +} + +func (tc *TbtcChain) hasFrostAuthorization() bool { + return tc.frostWalletRegistry != nil && tc.frostSortitionPool != nil +} + +func connectFrostDkgValidator( + config ethereum.Config, + baseChain *baseChain, +) (*frostvalidatorabi.FrostDkgValidator, error) { + frostDkgValidatorAddress, err := config.ContractAddress( + FrostDkgValidatorContractName, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to resolve %s contract address: [%v]", + FrostDkgValidatorContractName, + err, + ) + } + + if frostDkgValidatorAddress == (common.Address{}) { + logger.Infof( + "%s contract address not configured; pre-submit FROST digest "+ + "view checks disabled", + FrostDkgValidatorContractName, + ) + return nil, nil + } + + frostDkgValidator, err := frostvalidatorabi.NewFrostDkgValidator( + frostDkgValidatorAddress, + baseChain.client, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to attach to FrostDkgValidator contract: [%v]", + err, + ) + } + + return frostDkgValidator, nil +} + // EcdsaWalletGroupParametersFromChain mirrors EcdsaDkgValidator sizing constants // when EcdsaDkgValidator contract address was configured under [ethereum] // contract addresses or developer.ecdsaDkgValidatorAddress alias. When absent, @@ -311,8 +475,9 @@ func (tc *TbtcChain) Staking() (chain.Address, error) { } // IsRecognized checks whether the given operator is recognized by the TbtcChain -// as eligible to join the network. If the operator has a stake delegation or -// had a stake delegation in the past, it will be recognized. +// as eligible to join the network. Legacy ECDSA operators are recognized if +// they have or had a stake delegation. FROST operators are recognized if the +// FROST registry maps them to a provider with non-zero eligible weight. func (tc *TbtcChain) IsRecognized(operatorPublicKey *operator.PublicKey) (bool, error) { operatorAddress, err := operatorPublicKeyToChainAddress(operatorPublicKey) if err != nil { @@ -333,28 +498,123 @@ func (tc *TbtcChain) IsRecognized(operatorPublicKey *operator.PublicKey) (bool, ) } + if (stakingProvider != common.Address{}) { + // Check if the staking provider has an owner. This check ensures that there + // is/was a stake delegation for the given staking provider. + _, _, _, hasStakeDelegation, err := tc.baseChain.RolesOf( + chain.Address(stakingProvider.Hex()), + ) + if err != nil { + return false, fmt.Errorf( + "failed to check stake delegation for staking provider [%v]: [%v]", + stakingProvider, + err, + ) + } + + if hasStakeDelegation { + return true, nil + } + } + + isRecognizedByFrost, err := tc.isRecognizedByFrostRegistry(operatorAddress) + if err != nil { + return false, err + } + if !isRecognizedByFrost { + return false, nil + } + + return true, nil +} + +func (tc *TbtcChain) isRecognizedByFrostRegistry( + operatorAddress common.Address, +) (bool, error) { + if !tc.hasFrostAuthorization() || + (tc.frostWalletRegistryAddr == common.Address{}) { + return false, nil + } + + out, err := tc.callFrostRegistryAuthorizationView( + "operatorToStakingProvider", + operatorAddress, + ) + if err != nil { + return false, fmt.Errorf( + "failed to map FROST operator [%v] to a provider: [%v]", + operatorAddress, + err, + ) + } + if len(out) != 1 { + return false, fmt.Errorf( + "unexpected FROST operatorToStakingProvider result length [%v]", + len(out), + ) + } + + stakingProvider := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) if (stakingProvider == common.Address{}) { return false, nil } - // Check if the staking provider has an owner. This check ensures that there - // is/was a stake delegation for the given staking provider. - _, _, _, hasStakeDelegation, err := tc.baseChain.RolesOf( - chain.Address(stakingProvider.Hex()), + out, err = tc.callFrostRegistryAuthorizationView( + "eligibleStake", + stakingProvider, ) if err != nil { return false, fmt.Errorf( - "failed to check stake delegation for staking provider [%v]: [%v]", + "failed to get FROST eligible weight for provider [%v]: [%v]", stakingProvider, err, ) } + if len(out) != 1 { + return false, fmt.Errorf( + "unexpected FROST eligibleStake result length [%v]", + len(out), + ) + } - if !hasStakeDelegation { - return false, nil + eligibleWeight := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + return eligibleWeight.Sign() > 0, nil +} + +func (tc *TbtcChain) callFrostRegistryAuthorizationView( + method string, + args ...interface{}, +) ([]interface{}, error) { + var out []interface{} + + contract := bind.NewBoundContract( + tc.frostWalletRegistryAddr, + frostWalletRegistryAuthorizationABI, + tc.baseChain.client, + nil, + nil, + ) + + err := contract.Call( + &bind.CallOpts{From: tc.key.Address}, + &out, + method, + args..., + ) + if err != nil { + return nil, err } - return true, nil + return out, nil +} + +func mustParseABI(rawABI string) abi.ABI { + parsed, err := abi.JSON(strings.NewReader(rawABI)) + if err != nil { + panic(err) + } + + return parsed } // OperatorToStakingProvider returns the staking provider address for the @@ -363,7 +623,18 @@ func (tc *TbtcChain) IsRecognized(operatorPublicKey *operator.PublicKey) (bool, // false. If the staking provider has been registered, the address is not // empty and the boolean flag indicates true. func (tc *TbtcChain) OperatorToStakingProvider() (chain.Address, bool, error) { - stakingProvider, err := tc.walletRegistry.OperatorToStakingProvider(tc.key.Address) + var stakingProvider common.Address + var err error + + if tc.hasFrostAuthorization() { + stakingProvider, err = tc.frostWalletRegistry.OperatorToStakingProvider( + &bind.CallOpts{From: tc.key.Address}, + tc.key.Address, + ) + } else { + stakingProvider, err = tc.walletRegistry.OperatorToStakingProvider(tc.key.Address) + } + if err != nil { return "", false, fmt.Errorf( "failed to map operator [%v] to a staking provider: [%v]", @@ -386,9 +657,20 @@ func (tc *TbtcChain) OperatorToStakingProvider() (chain.Address, bool, error) { // If the authorized stake minus the pending authorization decrease // is below the minimum authorization, eligible stake is 0. func (tc *TbtcChain) EligibleStake(stakingProvider chain.Address) (*big.Int, error) { - eligibleStake, err := tc.walletRegistry.EligibleStake( - common.HexToAddress(stakingProvider.String()), - ) + stakingProviderAddress := common.HexToAddress(stakingProvider.String()) + + var eligibleStake *big.Int + var err error + + if tc.hasFrostAuthorization() { + eligibleStake, err = tc.frostWalletRegistry.EligibleStake( + &bind.CallOpts{From: tc.key.Address}, + stakingProviderAddress, + ) + } else { + eligibleStake, err = tc.walletRegistry.EligibleStake(stakingProviderAddress) + } + if err != nil { return nil, fmt.Errorf( "failed to get eligible stake for staking provider %s: [%w]", @@ -403,12 +685,23 @@ func (tc *TbtcChain) EligibleStake(stakingProvider chain.Address) (*big.Int, err // IsPoolLocked returns true if the sortition pool is locked and no state // changes are allowed. func (tc *TbtcChain) IsPoolLocked() (bool, error) { + if tc.hasFrostAuthorization() { + return tc.frostSortitionPool.IsLocked() + } + return tc.sortitionPool.IsLocked() } // IsOperatorInPool returns true if the operator is registered in // the sortition pool. func (tc *TbtcChain) IsOperatorInPool() (bool, error) { + if tc.hasFrostAuthorization() { + return tc.frostWalletRegistry.IsOperatorInPool( + &bind.CallOpts{From: tc.key.Address}, + tc.key.Address, + ) + } + return tc.walletRegistry.IsOperatorInPool(tc.key.Address) } @@ -419,12 +712,28 @@ func (tc *TbtcChain) IsOperatorInPool() (bool, error) { // If the operator is not in the sortition pool and their authorized stake // is non-zero, function returns false. func (tc *TbtcChain) IsOperatorUpToDate() (bool, error) { + if tc.hasFrostAuthorization() { + return tc.frostWalletRegistry.IsOperatorUpToDate( + &bind.CallOpts{From: tc.key.Address}, + tc.key.Address, + ) + } + return tc.walletRegistry.IsOperatorUpToDate(tc.key.Address) } // JoinSortitionPool executes a transaction to have the operator join the // sortition pool. func (tc *TbtcChain) JoinSortitionPool() error { + if tc.hasFrostAuthorization() { + return tc.submitFrostWalletRegistryTransaction( + "joinSortitionPool", + func(opts *bind.TransactOpts) (*types.Transaction, error) { + return tc.frostWalletRegistry.JoinSortitionPool(opts) + }, + ) + } + _, err := tc.walletRegistry.JoinSortitionPool() return err } @@ -432,6 +741,18 @@ func (tc *TbtcChain) JoinSortitionPool() error { // UpdateOperatorStatus executes a transaction to update the operator's // state in the sortition pool. func (tc *TbtcChain) UpdateOperatorStatus() error { + if tc.hasFrostAuthorization() { + return tc.submitFrostWalletRegistryTransaction( + "updateOperatorStatus", + func(opts *bind.TransactOpts) (*types.Transaction, error) { + return tc.frostWalletRegistry.UpdateOperatorStatus( + opts, + tc.key.Address, + ) + }, + ) + } + _, err := tc.walletRegistry.UpdateOperatorStatus(tc.key.Address) return err } @@ -439,42 +760,82 @@ func (tc *TbtcChain) UpdateOperatorStatus() error { // IsEligibleForRewards checks whether the operator is eligible for rewards // or not. func (tc *TbtcChain) IsEligibleForRewards() (bool, error) { + if tc.hasFrostAuthorization() { + return tc.frostSortitionPool.IsEligibleForRewards(tc.key.Address) + } + return tc.sortitionPool.IsEligibleForRewards(tc.key.Address) } // Checks whether the operator is able to restore their eligibility for // rewards right away. func (tc *TbtcChain) CanRestoreRewardEligibility() (bool, error) { + if tc.hasFrostAuthorization() { + return tc.frostSortitionPool.CanRestoreRewardEligibility(tc.key.Address) + } + return tc.sortitionPool.CanRestoreRewardEligibility(tc.key.Address) } // Restores reward eligibility for the operator. func (tc *TbtcChain) RestoreRewardEligibility() error { + if tc.hasFrostAuthorization() { + _, err := tc.frostSortitionPool.RestoreRewardEligibility(tc.key.Address) + return err + } + _, err := tc.sortitionPool.RestoreRewardEligibility(tc.key.Address) return err } // Returns true if the chaosnet phase is active, false otherwise. func (tc *TbtcChain) IsChaosnetActive() (bool, error) { + if tc.hasFrostAuthorization() { + return tc.frostSortitionPool.IsChaosnetActive() + } + return tc.sortitionPool.IsChaosnetActive() } // Returns true if operator is a beta operator, false otherwise. // Chaosnet status does not matter. func (tc *TbtcChain) IsBetaOperator() (bool, error) { + if tc.hasFrostAuthorization() { + return tc.frostSortitionPool.IsBetaOperator(tc.key.Address) + } + return tc.sortitionPool.IsBetaOperator(tc.key.Address) } -// GetOperatorID returns the ID number of the given operator address. An ID -// number of 0 means the operator has not been allocated an ID number yet. +// GetOperatorID returns the legacy ECDSA sortition pool ID number of the given +// operator address. An ID number of 0 means the operator has not been allocated +// an ID number yet. +// +// This method intentionally remains bound to the legacy ECDSA sortition pool +// even when FROST authorization is configured. Existing ECDSA tBTC flows such +// as DKG approval, inactivity claims, and tbtcpg moving-funds claims compare +// against ECDSA WalletRegistry member IDs. FROST DKG paths use +// SelectFrostGroup and the FROST sortition pool directly. func (tc *TbtcChain) GetOperatorID( operatorAddress chain.Address, ) (chain.OperatorID, error) { - return tc.sortitionPool.GetOperatorID( + return getOperatorID( + tc.sortitionPool, common.HexToAddress(operatorAddress.String()), ) } +type operatorIDResolver interface { + GetOperatorID(operator common.Address) (chain.OperatorID, error) +} + +func getOperatorID( + sortitionPool operatorIDResolver, + operatorAddress common.Address, +) (chain.OperatorID, error) { + return sortitionPool.GetOperatorID(operatorAddress) +} + // SelectGroup returns the group members selected for the current group // selection. The function returns an error if the chain's state does not allow // for group selection at the moment. @@ -1297,6 +1658,75 @@ func (tc *TbtcChain) PastDepositRevealedEvents( return convertedEvents, err } +func (tc *TbtcChain) PastTaprootDepositRevealedEvents( + filter *tbtc.DepositRevealedEventFilter, +) ([]*tbtc.TaprootDepositRevealedEvent, error) { + var startBlock uint64 + var endBlock *uint64 + var depositor []common.Address + var walletPublicKeyHash [][20]byte + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + + for _, d := range filter.Depositor { + depositor = append(depositor, common.HexToAddress(d.String())) + } + + walletPublicKeyHash = filter.WalletPublicKeyHash + } + + events, err := tc.bridge.PastTaprootDepositRevealedEvents( + startBlock, + endBlock, + depositor, + walletPublicKeyHash, + ) + if err != nil { + return nil, err + } + + convertedEvents := make([]*tbtc.TaprootDepositRevealedEvent, 0) + for _, event := range events { + var vault *chain.Address + if event.Vault != [20]byte{} { + v := chain.Address(event.Vault.Hex()) + vault = &v + } + + convertedEvent := &tbtc.TaprootDepositRevealedEvent{ + // We can map the event.FundingTxHash field directly to the + // bitcoin.Hash type. This is because event.FundingTxHash is + // a [32]byte type representing a hash in the bitcoin.InternalByteOrder, + // just as bitcoin.Hash assumes. + FundingTxHash: event.FundingTxHash, + FundingOutputIndex: event.FundingOutputIndex, + Depositor: chain.Address(event.Depositor.Hex()), + Amount: event.Amount, + BlindingFactor: event.BlindingFactor, + WalletPublicKeyHash: event.WalletPubKeyHash, + WalletXOnlyPublicKey: event.WalletXOnlyPublicKey, + RefundPublicKeyHash: event.RefundPubKeyHash, + RefundXOnlyPublicKey: event.RefundXOnlyPublicKey, + RefundLocktime: event.RefundLocktime, + Vault: vault, + BlockNumber: event.Raw.BlockNumber, + } + + convertedEvents = append(convertedEvents, convertedEvent) + } + + sort.SliceStable( + convertedEvents, + func(i, j int) bool { + return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber + }, + ) + + return convertedEvents, err +} + func (tc *TbtcChain) PastRedemptionRequestedEvents( filter *tbtc.RedemptionRequestedEventFilter, ) ([]*tbtc.RedemptionRequestedEvent, error) { @@ -1420,35 +1850,79 @@ func (tc *TbtcChain) PastNewWalletRegisteredEvents( ) ([]*tbtc.NewWalletRegisteredEvent, error) { var startBlock uint64 var endBlock *uint64 + var walletID [][32]byte var ecdsaWalletID [][32]byte var walletPublicKeyHash [][20]byte if filter != nil { startBlock = filter.StartBlock endBlock = filter.EndBlock + walletID = filter.WalletID ecdsaWalletID = filter.EcdsaWalletID walletPublicKeyHash = filter.WalletPublicKeyHash } - events, err := tc.bridge.PastNewWalletRegisteredEvents( + return pastNewWalletRegisteredEvents( + startBlock, + endBlock, + walletID, + ecdsaWalletID, + walletPublicKeyHash, + tc.bridge, + tc.bridge.PastNewWalletRegisteredEvents, + ) +} + +type pastNewWalletRegisteredEventsFn func( + startBlock uint64, + endBlock *uint64, + ecdsaWalletID [][32]byte, + walletPubKeyHash [][20]byte, +) ([]*tbtcabi.BridgeNewWalletRegistered, error) + +func pastNewWalletRegisteredEvents( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + bridge any, + pastLegacyEvents pastNewWalletRegisteredEventsFn, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + convertedEvents, err := pastNewWalletRegisteredV2Events( startBlock, endBlock, + walletID, ecdsaWalletID, walletPublicKeyHash, + bridge, ) if err != nil { return nil, err } - convertedEvents := make([]*tbtc.NewWalletRegisteredEvent, 0) - for _, event := range events { - convertedEvent := &tbtc.NewWalletRegisteredEvent{ - EcdsaWalletID: event.EcdsaWalletID, - WalletPublicKeyHash: event.WalletPubKeyHash, - BlockNumber: event.Raw.BlockNumber, + // Fallback for legacy deployments that do not emit NewWalletRegisteredV2. + if len(convertedEvents) == 0 && len(walletID) == 0 { + legacyEvents, err := pastLegacyEvents( + startBlock, + endBlock, + ecdsaWalletID, + walletPublicKeyHash, + ) + if err != nil { + return nil, err } - convertedEvents = append(convertedEvents, convertedEvent) + for _, event := range legacyEvents { + convertedEvent := &tbtc.NewWalletRegisteredEvent{ + WalletID: tbtc.DeriveLegacyWalletID(event.WalletPubKeyHash), + EcdsaWalletID: event.EcdsaWalletID, + WalletPublicKeyHash: event.WalletPubKeyHash, + BlockNumber: event.Raw.BlockNumber, + } + + convertedEvents = append(convertedEvents, convertedEvent) + } } sort.SliceStable( @@ -1458,7 +1932,160 @@ func (tc *TbtcChain) PastNewWalletRegisteredEvents( }, ) - return convertedEvents, err + return convertedEvents, nil +} + +func pastNewWalletRegisteredV2Events( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + bridge any, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + if bridge == nil { + return nil, nil + } + + bridgeValue := reflect.ValueOf(bridge) + pastV2Events := bridgeValue.MethodByName("PastNewWalletRegisteredV2Events") + if !pastV2Events.IsValid() { + return nil, nil + } + + var ( + results []reflect.Value + callErr error + ) + + func() { + defer func() { + if recovered := recover(); recovered != nil { + callErr = fmt.Errorf( + "panic calling PastNewWalletRegisteredV2Events: [%v]", + recovered, + ) + } + }() + + results = pastV2Events.Call( + []reflect.Value{ + reflect.ValueOf(startBlock), + reflect.ValueOf(endBlock), + reflect.ValueOf(walletID), + reflect.ValueOf(ecdsaWalletID), + reflect.ValueOf(walletPublicKeyHash), + }, + ) + }() + + if callErr != nil { + return nil, callErr + } + + if len(results) != 2 { + return nil, fmt.Errorf( + "unexpected PastNewWalletRegisteredV2Events result count: [%v]", + len(results), + ) + } + + if !results[1].IsNil() { + err, ok := results[1].Interface().(error) + if !ok { + return nil, fmt.Errorf( + "unexpected PastNewWalletRegisteredV2Events error type: [%T]", + results[1].Interface(), + ) + } + + return nil, err + } + + eventsValue := results[0] + if eventsValue.Kind() != reflect.Slice { + return nil, fmt.Errorf( + "unexpected PastNewWalletRegisteredV2Events events type: [%v]", + eventsValue.Kind(), + ) + } + + convertedEvents := make([]*tbtc.NewWalletRegisteredEvent, 0, eventsValue.Len()) + for i := 0; i < eventsValue.Len(); i++ { + eventValue := eventsValue.Index(i) + if eventValue.Kind() == reflect.Pointer { + if eventValue.IsNil() { + continue + } + + eventValue = eventValue.Elem() + } + + if eventValue.Kind() != reflect.Struct { + return nil, fmt.Errorf( + "unexpected NewWalletRegisteredV2 event kind: [%v]", + eventValue.Kind(), + ) + } + + walletIDField := eventValue.FieldByName("WalletID") + ecdsaWalletIDField := eventValue.FieldByName("EcdsaWalletID") + walletPubKeyHashField := eventValue.FieldByName("WalletPubKeyHash") + if !walletPubKeyHashField.IsValid() { + walletPubKeyHashField = eventValue.FieldByName("WalletPublicKeyHash") + } + rawField := eventValue.FieldByName("Raw") + if !rawField.IsValid() { + return nil, fmt.Errorf( + "unexpected NewWalletRegisteredV2 raw event payload at index [%v]", + i, + ) + } + + if rawField.Kind() == reflect.Pointer { + if rawField.IsNil() { + return nil, fmt.Errorf("unexpected nil raw event payload") + } + + rawField = rawField.Elem() + } + + if rawField.Kind() != reflect.Struct { + return nil, fmt.Errorf( + "unexpected NewWalletRegisteredV2 raw event payload kind at index [%v]: [%v]", + i, + rawField.Kind(), + ) + } + + blockNumberField := rawField.FieldByName("BlockNumber") + + if !walletIDField.IsValid() || + walletIDField.Type() != reflect.TypeOf([32]byte{}) || + !ecdsaWalletIDField.IsValid() || + ecdsaWalletIDField.Type() != reflect.TypeOf([32]byte{}) || + !walletPubKeyHashField.IsValid() || + walletPubKeyHashField.Type() != reflect.TypeOf([20]byte{}) || + !blockNumberField.IsValid() || + blockNumberField.Kind() != reflect.Uint64 { + return nil, fmt.Errorf( + "unexpected NewWalletRegisteredV2 event shape at index [%v]", + i, + ) + } + + convertedEvents = append( + convertedEvents, + &tbtc.NewWalletRegisteredEvent{ + WalletID: walletIDField.Interface().([32]byte), + EcdsaWalletID: ecdsaWalletIDField.Interface().([32]byte), + WalletPublicKeyHash: walletPubKeyHashField.Interface().([20]byte), + BlockNumber: blockNumberField.Uint(), + }, + ) + } + + return convertedEvents, nil } func (tc *TbtcChain) CalculateWalletID( @@ -1494,6 +2121,26 @@ func (tc *TbtcChain) IsWalletRegistered(EcdsaWalletID [32]byte) (bool, error) { return isWalletRegistered, nil } +func (tc *TbtcChain) IsFrostWalletRegistered(walletID [32]byte) (bool, error) { + if tc.frostWalletRegistry == nil { + return false, fmt.Errorf("FROST wallet registry is not configured") + } + + isWalletRegistered, err := tc.frostWalletRegistry.IsWalletRegistered( + &bind.CallOpts{}, + walletID, + ) + if err != nil { + return false, fmt.Errorf( + "cannot check if FROST wallet with ID [0x%x] is registered: [%v]", + walletID, + err, + ) + } + + return isWalletRegistered, nil +} + func (tc *TbtcChain) GetWallet( walletPublicKeyHash [20]byte, ) (*tbtc.WalletChainData, error) { @@ -1519,7 +2166,17 @@ func (tc *TbtcChain) GetWallet( return nil, fmt.Errorf("cannot parse wallet state: [%v]", err) } + walletID, err := resolveWalletID( + tc.bridge, + walletPublicKeyHash, + wallet.EcdsaWalletID, + ) + if err != nil { + return nil, err + } + return &tbtc.WalletChainData{ + WalletID: walletID, EcdsaWalletID: wallet.EcdsaWalletID, MainUtxoHash: wallet.MainUtxoHash, PendingRedemptionsValue: wallet.PendingRedemptionsValue, @@ -1532,6 +2189,120 @@ func (tc *TbtcChain) GetWallet( }, nil } +func (tc *TbtcChain) WalletPublicKeyHashForWalletID( + walletID [32]byte, +) ([20]byte, error) { + return resolveWalletPublicKeyHashForWalletID( + walletID, + tc.bridge, + ) +} + +type walletIDForWalletPublicKeyHashFn interface { + WalletID(walletPublicKeyHash [20]byte) ([32]byte, error) +} + +func walletIDForWalletPublicKeyHash( + bridge any, + walletPublicKeyHash [20]byte, +) ([32]byte, error) { + resolver, ok := bridge.(walletIDForWalletPublicKeyHashFn) + if !ok { + return [32]byte{}, fmt.Errorf("wallet ID accessor unavailable") + } + + return resolver.WalletID(walletPublicKeyHash) +} + +// resolveWalletID returns the canonical wallet ID for the wallet identified by +// walletPublicKeyHash. ecdsaWalletID is that wallet's ECDSA wallet ID from the +// Bridge record -- zero for FROST wallets, non-zero for legacy ECDSA wallets. +// +// On an accessor error the fallback is routed by SCHEME, not by error type +// (which cannot reliably distinguish a legacy on-chain Bridge -- whose walletID +// eth_call returns a normal RPC/ABI error even when the node uses the current +// binding -- from a transient failure): +// +// - A legacy ECDSA wallet's canonical wallet ID equals its legacy derivation, +// so falling back is correct, and it is the only option on a legacy Bridge +// whose contract lacks the walletID accessor. +// - A FROST wallet requires its canonical wallet ID; the legacy derivation +// would be a different value and would select the wrong (P2WPKH vs P2TR) +// wallet script, so the error is surfaced instead of falling back. A FROST +// wallet only exists on a canonical-ID Bridge, so such an error is genuinely +// transient. +func resolveWalletID( + bridge any, + walletPublicKeyHash [20]byte, + ecdsaWalletID [32]byte, +) ([32]byte, error) { + walletID, err := walletIDForWalletPublicKeyHash(bridge, walletPublicKeyHash) + if err == nil { + return walletID, nil + } + + if ecdsaWalletID == ([32]byte{}) { + return [32]byte{}, fmt.Errorf( + "cannot resolve canonical wallet ID for FROST wallet [0x%x]: [%w]", + walletPublicKeyHash, + err, + ) + } + + return tbtc.DeriveLegacyWalletID(walletPublicKeyHash), nil +} + +type walletPublicKeyHashForWalletIDFn interface { + WalletPubKeyHashForWalletID(walletID [32]byte) ([20]byte, error) +} + +func resolveWalletPublicKeyHashForWalletID( + walletID [32]byte, + bridge any, +) ([20]byte, error) { + resolveCanonical, ok := bridge.(walletPublicKeyHashForWalletIDFn) + + var walletPublicKeyHash [20]byte + var err error + if ok { + walletPublicKeyHash, err = resolveCanonical.WalletPubKeyHashForWalletID(walletID) + } else { + err = fmt.Errorf("wallet public key hash accessor unavailable") + } + + if err == nil { + if walletPublicKeyHash != [20]byte{} { + return walletPublicKeyHash, nil + } + } + + legacyWalletPublicKeyHash, ok := tbtc.WalletPublicKeyHashFromLegacyWalletID(walletID) + if ok { + if err != nil { + logger.Infof( + "canonical wallet public key hash resolution failed for wallet ID [0x%x]; using legacy derivation: [%v]", + walletID, + err, + ) + } + + return legacyWalletPublicKeyHash, nil + } + + if err != nil { + return [20]byte{}, fmt.Errorf( + "cannot resolve wallet public key hash for wallet ID [0x%x]: [%v]", + walletID, + err, + ) + } + + return [20]byte{}, fmt.Errorf( + "wallet public key hash not found for wallet ID [0x%x]", + walletID, + ) +} + func (tc *TbtcChain) OnWalletClosed( handler func(event *tbtc.WalletClosedEvent), ) subscription.EventSubscription { @@ -2040,6 +2811,58 @@ func (tc *TbtcChain) ValidateDepositSweepProposal( return nil } +func (tc *TbtcChain) ValidateTaprootDepositSweepProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.DepositSweepProposal, + depositsExtraInfo []struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + dei := make( + []tbtcabi.WalletProposalValidatorTaprootDepositExtraInfo, + len(depositsExtraInfo), + ) + for i, depositExtraInfo := range depositsExtraInfo { + fundingTx := tbtcabi.BitcoinTxInfo2{ + Version: depositExtraInfo.FundingTx.SerializeVersion(), + InputVector: depositExtraInfo.FundingTx.SerializeInputs(), + OutputVector: depositExtraInfo.FundingTx.SerializeOutputs(), + Locktime: depositExtraInfo.FundingTx.SerializeLocktime(), + } + + if !depositExtraInfo.Deposit.IsTaproot() { + return fmt.Errorf("deposit extra info [%v] is not Taproot-native", i) + } + + dei[i] = tbtcabi.WalletProposalValidatorTaprootDepositExtraInfo{ + FundingTx: fundingTx, + BlindingFactor: depositExtraInfo.Deposit.BlindingFactor, + WalletPubKeyHash: depositExtraInfo.Deposit.WalletPublicKeyHash, + WalletXOnlyPublicKey: *depositExtraInfo.Deposit.WalletXOnlyPublicKey, + RefundPubKeyHash: depositExtraInfo.Deposit.RefundPublicKeyHash, + RefundXOnlyPublicKey: *depositExtraInfo.Deposit.RefundXOnlyPublicKey, + RefundLocktime: depositExtraInfo.Deposit.RefundLocktime, + } + } + + valid, err := tc.walletProposalValidator.ValidateTaprootDepositSweepProposal( + convertDepositSweepProposalToAbiType(walletPublicKeyHash, proposal), + dei, + ) + if err != nil { + return fmt.Errorf("validation failed: [%v]", err) + } + + // Should never happen because `validateTaprootDepositSweepProposal` + // returns true or reverts (returns an error) but do the check just in case. + if !valid { + return fmt.Errorf("unexpected validation result") + } + + return nil +} + func (tc *TbtcChain) GetDepositSweepMaxSize() (uint16, error) { return tc.walletProposalValidator.DEPOSITSWEEPMAXSIZE() } @@ -2457,3 +3280,12 @@ func (tc *TbtcChain) GetRedemptionDelay( func (tc *TbtcChain) GetDepositMinAge() (uint32, error) { return tc.walletProposalValidator.DEPOSITMINAGE() } + +func (tc *TbtcChain) CurrentBlockTimestamp() (time.Time, error) { + currentBlock, err := tc.currentBlockHeader() + if err != nil { + return time.Time{}, fmt.Errorf("cannot get current block: [%v]", err) + } + + return time.Unix(int64(currentBlock.Time), 0), nil +} diff --git a/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go b/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go index e76e6f779f..60d00d2955 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go @@ -46,13 +46,6 @@ type BitcoinTxProof struct { CoinbaseProof []byte } -// BitcoinTxRSVSignature is an auto generated low-level Go binding around an user-defined struct. -type BitcoinTxRSVSignature struct { - R [32]byte - S [32]byte - V uint8 -} - // BitcoinTxUTXO is an auto generated low-level Go binding around an user-defined struct. type BitcoinTxUTXO struct { TxHash [32]byte @@ -81,12 +74,16 @@ type DepositDepositRevealInfo struct { Vault common.Address } -// FraudFraudChallenge is an auto generated low-level Go binding around an user-defined struct. -type FraudFraudChallenge struct { - Challenger common.Address - DepositAmount *big.Int - ReportedAt uint32 - Resolved bool +// DepositTaprootDepositRevealInfo is an auto generated low-level Go binding around an user-defined struct. +type DepositTaprootDepositRevealInfo struct { + FundingOutputIndex uint32 + BlindingFactor [8]byte + WalletPubKeyHash [20]byte + WalletXOnlyPublicKey [32]byte + RefundPubKeyHash [20]byte + RefundXOnlyPublicKey [32]byte + RefundLocktime [4]byte + Vault common.Address } // MovingFundsMovedFundsSweepRequest is an auto generated low-level Go binding around an user-defined struct. @@ -121,7 +118,7 @@ type WalletsWallet struct { // BridgeMetaData contains all meta data concerning the Bridge contract. var BridgeMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"DepositParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"DepositRevealed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"DepositsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeatTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"FraudParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldGovernance\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"GovernanceTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"}],\"name\":\"MovedFundsSweepTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovedFundsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsBelowDustReported\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"submitter\",\"type\":\"address\"}],\"name\":\"MovingFundsCommitmentSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovingFundsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"MovingFundsParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimeoutReset\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"NewWalletRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"RedemptionParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"}],\"name\":\"RedemptionRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"RedemptionTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"RedemptionWatchtowerSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"redemptionTxHash\",\"type\":\"bytes32\"}],\"name\":\"RedemptionsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"SpvMaintainerStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"TreasuryUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"VaultStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosing\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletMovingFunds\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"WalletParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletTerminated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletCreatedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletHeartbeatFailedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletPubKeyHash\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"contractReferences\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"bank\",\"type\":\"address\"},{\"internalType\":\"contractIRelay\",\"name\":\"relay\",\"type\":\"address\"},{\"internalType\":\"contractIWalletRegistry\",\"name\":\"ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"contractReimbursementPool\",\"name\":\"reimbursementPool\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimage\",\"type\":\"bytes\"},{\"internalType\":\"bool\",\"name\":\"witness\",\"type\":\"bool\"}],\"name\":\"defeatFraudChallenge\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"heartbeatMessage\",\"type\":\"bytes\"}],\"name\":\"defeatFraudChallengeWithHeartbeat\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"depositParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"deposits\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"revealedAt\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"sweptAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"internalType\":\"structDeposit.DepositRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"challengeKey\",\"type\":\"uint256\"}],\"name\":\"fraudChallenges\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"depositAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"reportedAt\",\"type\":\"uint32\"},{\"internalType\":\"bool\",\"name\":\"resolved\",\"type\":\"bool\"}],\"internalType\":\"structFraud.FraudChallenge\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"fraudParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRedemptionWatchtower\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_bank\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_relay\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_treasury\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"addresspayable\",\"name\":\"_reimbursementPool\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"_txProofDifficultyFactor\",\"type\":\"uint96\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"isVaultTrusted\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"liveWalletsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"requestKey\",\"type\":\"uint256\"}],\"name\":\"movedFundsSweepRequests\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint64\",\"name\":\"value\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"enumMovingFunds.MovedFundsSweepRequestState\",\"name\":\"state\",\"type\":\"uint8\"}],\"internalType\":\"structMovingFunds.MovedFundsSweepRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"movingFundsParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"}],\"name\":\"notifyFraudChallengeDefeatTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovedFundsSweepTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyMovingFundsBelowDust\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionVeto\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyWalletCloseable\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"notifyWalletClosingPeriodElapsed\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"pendingRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"redemptionData\",\"type\":\"bytes\"}],\"name\":\"receiveBalanceApproval\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"redemptionParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"activeWalletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"requestNewWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"requestRedemption\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"resetMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"}],\"name\":\"revealDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"name\":\"revealDepositWithExtraData\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"setRedemptionWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setSpvMaintainerStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setVaultStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"utxoKey\",\"type\":\"uint256\"}],\"name\":\"spentMainUTXOs\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"submitDepositSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"},{\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"}],\"internalType\":\"structBitcoinTx.RSVSignature\",\"name\":\"signature\",\"type\":\"tuple\"}],\"name\":\"submitFraudChallenge\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"submitMovedFundsSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"uint256\",\"name\":\"walletMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"}],\"name\":\"submitMovingFundsCommitment\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"movingFundsTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"movingFundsProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitMovingFundsProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"redemptionTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"redemptionProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitRedemptionProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"timedOutRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasury\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"txProofDifficultyFactor\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"updateDepositParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateFraudParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateMovingFundsParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateRedemptionParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"updateTreasury\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"updateWalletParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"walletParameters\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"wallets\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"AddressIsZero\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"EcdsaFraudRouterAddressZero\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"EcdsaFraudRouterAlreadySet\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"FrostWalletRegistryAddressZero\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"FrostWalletRegistryAlreadySet\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"LifecycleRouterAddressZero\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"LifecycleRouterAlreadySet\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"MigrateLegacyFraudChallengesNotImplemented\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"P2TRFraudRouterAddressZero\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"P2TRFraudRouterAlreadySet\",\"type\":\"error\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"DepositParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"DepositRevealed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newVault\",\"type\":\"address\"}],\"name\":\"DepositVaultFixed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"DepositsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"ecdsaFraudRouter\",\"type\":\"address\"}],\"name\":\"EcdsaFraudRouterSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"EcdsaRetired\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"FraudParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"frostWalletRegistry\",\"type\":\"address\"}],\"name\":\"FrostWalletRegistrySet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldGovernance\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"GovernanceTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint8\",\"name\":\"routerKind\",\"type\":\"uint8\"},{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"challengeKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"depositAmount\",\"type\":\"uint256\"}],\"name\":\"LegacyFraudChallengeMigrated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"lifecycleRouter\",\"type\":\"address\"}],\"name\":\"LifecycleRouterSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"}],\"name\":\"MovedFundsSweepTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovedFundsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsBelowDustReported\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"submitter\",\"type\":\"address\"}],\"name\":\"MovingFundsCommitmentSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovingFundsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"MovingFundsParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimeoutReset\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"}],\"name\":\"NewFrostWalletRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegisteredV2\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"NewWalletRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"enumBridgeState.WalletScheme\",\"name\":\"scheme\",\"type\":\"uint8\"}],\"name\":\"NewWalletSchemeSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"p2trFraudRouter\",\"type\":\"address\"}],\"name\":\"P2TRFraudRouterSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"rebateStaking\",\"type\":\"address\"}],\"name\":\"RebateStakingSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"RedemptionParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"}],\"name\":\"RedemptionRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"RedemptionTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"RedemptionWatchtowerSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"redemptionTxHash\",\"type\":\"bytes32\"}],\"name\":\"RedemptionsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"SpvMaintainerStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"walletXOnlyPublicKey\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"refundXOnlyPublicKey\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"TaprootDepositRevealed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"TreasuryUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"VaultStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosing\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletMovingFunds\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"WalletParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletTerminated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletHeartbeatFailedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"xOnlyOutputKey\",\"type\":\"bytes32\"}],\"name\":\"__frostWalletCreatedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletID\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletPubKeyHash\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"contractReferences\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"bank\",\"type\":\"address\"},{\"internalType\":\"contractIRelay\",\"name\":\"relay\",\"type\":\"address\"},{\"internalType\":\"contractIWalletRegistry\",\"name\":\"ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"contractReimbursementPool\",\"name\":\"reimbursementPool\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"depositParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"deposits\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"revealedAt\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"sweptAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"internalType\":\"structDeposit.DepositRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"ecdsaFraudRouter\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"ecdsaRetired\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"fraudParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"frostLifecycleContext\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"frostRegistry\",\"type\":\"address\"},{\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRebateStaking\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRedemptionWatchtower\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_bank\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_relay\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_treasury\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"addresspayable\",\"name\":\"_reimbursementPool\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"_txProofDifficultyFactor\",\"type\":\"uint96\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"initializeV2_FixVaultZeroDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"isVaultTrusted\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"liveWalletsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint8\",\"name\":\"routerKind\",\"type\":\"uint8\"},{\"internalType\":\"uint256[]\",\"name\":\"challengeKeys\",\"type\":\"uint256[]\"}],\"name\":\"migrateLegacyFraudChallenges\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"requestKey\",\"type\":\"uint256\"}],\"name\":\"movedFundsSweepRequests\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint64\",\"name\":\"value\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"enumMovingFunds.MovedFundsSweepRequestState\",\"name\":\"state\",\"type\":\"uint8\"}],\"internalType\":\"structMovingFunds.MovedFundsSweepRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"movingFundsParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovedFundsSweepTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyMovingFundsBelowDust\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionVeto\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyWalletCloseable\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"notifyWalletClosingPeriodElapsed\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"p2trFraudRouter\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"pendingRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"redemptionData\",\"type\":\"bytes\"}],\"name\":\"receiveBalanceApproval\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"redemptionParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"activeWalletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"requestNewWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"requestRedemption\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"resetMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"retireEcdsa\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"}],\"name\":\"revealDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"name\":\"revealDepositWithExtraData\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"walletXOnlyPublicKey\",\"type\":\"bytes32\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"refundXOnlyPublicKey\",\"type\":\"bytes32\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.TaprootDepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"}],\"name\":\"revealTaprootDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"walletXOnlyPublicKey\",\"type\":\"bytes32\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"refundXOnlyPublicKey\",\"type\":\"bytes32\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.TaprootDepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"name\":\"revealTaprootDepositWithExtraData\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"ecdsaFraudRouter\",\"type\":\"address\"}],\"name\":\"setEcdsaFraudRouter\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"frostWalletRegistry\",\"type\":\"address\"}],\"name\":\"setFrostWalletRegistry\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"lifecycleRouter\",\"type\":\"address\"}],\"name\":\"setLifecycleRouter\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"p2trFraudRouter\",\"type\":\"address\"}],\"name\":\"setP2TRFraudRouter\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"rebateStaking\",\"type\":\"address\"}],\"name\":\"setRebateStaking\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"setRedemptionWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setSpvMaintainerStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setVaultStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"}],\"name\":\"slashWalletForFraud\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"}],\"name\":\"slashWalletForP2TRFraud\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"utxoKey\",\"type\":\"uint256\"}],\"name\":\"spentMainUTXOs\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"submitDepositSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"submitMovedFundsSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"uint256\",\"name\":\"walletMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"}],\"name\":\"submitMovingFundsCommitment\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"movingFundsTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"movingFundsProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitMovingFundsProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"redemptionTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"redemptionProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitRedemptionProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"timedOutRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasury\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"txProofDifficultyFactor\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"updateDepositParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateFraudParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateMovingFundsParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateRedemptionParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"updateTreasury\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"updateWalletParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"walletID\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"walletParameters\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"walletId\",\"type\":\"bytes32\"}],\"name\":\"walletPubKeyHashForWalletID\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"wallets\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"walletId\",\"type\":\"bytes32\"}],\"name\":\"walletsByWalletID\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", } // BridgeABI is the input ABI used to generate the binding from. @@ -270,6 +267,37 @@ func (_Bridge *BridgeTransactorRaw) Transact(opts *bind.TransactOpts, method str return _Bridge.Contract.contract.Transact(opts, method, params...) } +// ActiveWalletID is a free data retrieval call binding the contract method 0x160c1730. +// +// Solidity: function activeWalletID() view returns(bytes32) +func (_Bridge *BridgeCaller) ActiveWalletID(opts *bind.CallOpts) ([32]byte, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "activeWalletID") + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// ActiveWalletID is a free data retrieval call binding the contract method 0x160c1730. +// +// Solidity: function activeWalletID() view returns(bytes32) +func (_Bridge *BridgeSession) ActiveWalletID() ([32]byte, error) { + return _Bridge.Contract.ActiveWalletID(&_Bridge.CallOpts) +} + +// ActiveWalletID is a free data retrieval call binding the contract method 0x160c1730. +// +// Solidity: function activeWalletID() view returns(bytes32) +func (_Bridge *BridgeCallerSession) ActiveWalletID() ([32]byte, error) { + return _Bridge.Contract.ActiveWalletID(&_Bridge.CallOpts) +} + // ActiveWalletPubKeyHash is a free data retrieval call binding the contract method 0xded1d24a. // // Solidity: function activeWalletPubKeyHash() view returns(bytes20) @@ -442,35 +470,66 @@ func (_Bridge *BridgeCallerSession) Deposits(depositKey *big.Int) (DepositDeposi return _Bridge.Contract.Deposits(&_Bridge.CallOpts, depositKey) } -// FraudChallenges is a free data retrieval call binding the contract method 0x33e957cb. +// EcdsaFraudRouter is a free data retrieval call binding the contract method 0x9fa00083. +// +// Solidity: function ecdsaFraudRouter() view returns(address) +func (_Bridge *BridgeCaller) EcdsaFraudRouter(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "ecdsaFraudRouter") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// EcdsaFraudRouter is a free data retrieval call binding the contract method 0x9fa00083. +// +// Solidity: function ecdsaFraudRouter() view returns(address) +func (_Bridge *BridgeSession) EcdsaFraudRouter() (common.Address, error) { + return _Bridge.Contract.EcdsaFraudRouter(&_Bridge.CallOpts) +} + +// EcdsaFraudRouter is a free data retrieval call binding the contract method 0x9fa00083. +// +// Solidity: function ecdsaFraudRouter() view returns(address) +func (_Bridge *BridgeCallerSession) EcdsaFraudRouter() (common.Address, error) { + return _Bridge.Contract.EcdsaFraudRouter(&_Bridge.CallOpts) +} + +// EcdsaRetired is a free data retrieval call binding the contract method 0xea3257e3. // -// Solidity: function fraudChallenges(uint256 challengeKey) view returns((address,uint256,uint32,bool)) -func (_Bridge *BridgeCaller) FraudChallenges(opts *bind.CallOpts, challengeKey *big.Int) (FraudFraudChallenge, error) { +// Solidity: function ecdsaRetired() view returns(bool) +func (_Bridge *BridgeCaller) EcdsaRetired(opts *bind.CallOpts) (bool, error) { var out []interface{} - err := _Bridge.contract.Call(opts, &out, "fraudChallenges", challengeKey) + err := _Bridge.contract.Call(opts, &out, "ecdsaRetired") if err != nil { - return *new(FraudFraudChallenge), err + return *new(bool), err } - out0 := *abi.ConvertType(out[0], new(FraudFraudChallenge)).(*FraudFraudChallenge) + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) return out0, err } -// FraudChallenges is a free data retrieval call binding the contract method 0x33e957cb. +// EcdsaRetired is a free data retrieval call binding the contract method 0xea3257e3. // -// Solidity: function fraudChallenges(uint256 challengeKey) view returns((address,uint256,uint32,bool)) -func (_Bridge *BridgeSession) FraudChallenges(challengeKey *big.Int) (FraudFraudChallenge, error) { - return _Bridge.Contract.FraudChallenges(&_Bridge.CallOpts, challengeKey) +// Solidity: function ecdsaRetired() view returns(bool) +func (_Bridge *BridgeSession) EcdsaRetired() (bool, error) { + return _Bridge.Contract.EcdsaRetired(&_Bridge.CallOpts) } -// FraudChallenges is a free data retrieval call binding the contract method 0x33e957cb. +// EcdsaRetired is a free data retrieval call binding the contract method 0xea3257e3. // -// Solidity: function fraudChallenges(uint256 challengeKey) view returns((address,uint256,uint32,bool)) -func (_Bridge *BridgeCallerSession) FraudChallenges(challengeKey *big.Int) (FraudFraudChallenge, error) { - return _Bridge.Contract.FraudChallenges(&_Bridge.CallOpts, challengeKey) +// Solidity: function ecdsaRetired() view returns(bool) +func (_Bridge *BridgeCallerSession) EcdsaRetired() (bool, error) { + return _Bridge.Contract.EcdsaRetired(&_Bridge.CallOpts) } // FraudParameters is a free data retrieval call binding the contract method 0x75b922d1. @@ -528,6 +587,82 @@ func (_Bridge *BridgeCallerSession) FraudParameters() (struct { return _Bridge.Contract.FraudParameters(&_Bridge.CallOpts) } +// FrostLifecycleContext is a free data retrieval call binding the contract method 0xd0ebc637. +// +// Solidity: function frostLifecycleContext(bytes20 walletPubKeyHash) view returns(address frostRegistry, bytes32 walletID) +func (_Bridge *BridgeCaller) FrostLifecycleContext(opts *bind.CallOpts, walletPubKeyHash [20]byte) (struct { + FrostRegistry common.Address + WalletID [32]byte +}, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "frostLifecycleContext", walletPubKeyHash) + + outstruct := new(struct { + FrostRegistry common.Address + WalletID [32]byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.FrostRegistry = *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + outstruct.WalletID = *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) + + return *outstruct, err + +} + +// FrostLifecycleContext is a free data retrieval call binding the contract method 0xd0ebc637. +// +// Solidity: function frostLifecycleContext(bytes20 walletPubKeyHash) view returns(address frostRegistry, bytes32 walletID) +func (_Bridge *BridgeSession) FrostLifecycleContext(walletPubKeyHash [20]byte) (struct { + FrostRegistry common.Address + WalletID [32]byte +}, error) { + return _Bridge.Contract.FrostLifecycleContext(&_Bridge.CallOpts, walletPubKeyHash) +} + +// FrostLifecycleContext is a free data retrieval call binding the contract method 0xd0ebc637. +// +// Solidity: function frostLifecycleContext(bytes20 walletPubKeyHash) view returns(address frostRegistry, bytes32 walletID) +func (_Bridge *BridgeCallerSession) FrostLifecycleContext(walletPubKeyHash [20]byte) (struct { + FrostRegistry common.Address + WalletID [32]byte +}, error) { + return _Bridge.Contract.FrostLifecycleContext(&_Bridge.CallOpts, walletPubKeyHash) +} + +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeCaller) GetRebateStaking(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "getRebateStaking") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeSession) GetRebateStaking() (common.Address, error) { + return _Bridge.Contract.GetRebateStaking(&_Bridge.CallOpts) +} + +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeCallerSession) GetRebateStaking() (common.Address, error) { + return _Bridge.Contract.GetRebateStaking(&_Bridge.CallOpts) +} + // GetRedemptionWatchtower is a free data retrieval call binding the contract method 0x5f3281ca. // // Solidity: function getRedemptionWatchtower() view returns(address) @@ -773,6 +908,37 @@ func (_Bridge *BridgeCallerSession) MovingFundsParameters() (struct { return _Bridge.Contract.MovingFundsParameters(&_Bridge.CallOpts) } +// P2trFraudRouter is a free data retrieval call binding the contract method 0xe3973b03. +// +// Solidity: function p2trFraudRouter() view returns(address) +func (_Bridge *BridgeCaller) P2trFraudRouter(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "p2trFraudRouter") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// P2trFraudRouter is a free data retrieval call binding the contract method 0xe3973b03. +// +// Solidity: function p2trFraudRouter() view returns(address) +func (_Bridge *BridgeSession) P2trFraudRouter() (common.Address, error) { + return _Bridge.Contract.P2trFraudRouter(&_Bridge.CallOpts) +} + +// P2trFraudRouter is a free data retrieval call binding the contract method 0xe3973b03. +// +// Solidity: function p2trFraudRouter() view returns(address) +func (_Bridge *BridgeCallerSession) P2trFraudRouter() (common.Address, error) { + return _Bridge.Contract.P2trFraudRouter(&_Bridge.CallOpts) +} + // PendingRedemptions is a free data retrieval call binding the contract method 0x03d952f7. // // Solidity: function pendingRedemptions(uint256 redemptionKey) view returns((address,uint64,uint64,uint64,uint32)) @@ -998,6 +1164,37 @@ func (_Bridge *BridgeCallerSession) TxProofDifficultyFactor() (*big.Int, error) return _Bridge.Contract.TxProofDifficultyFactor(&_Bridge.CallOpts) } +// WalletID is a free data retrieval call binding the contract method 0x858c14bd. +// +// Solidity: function walletID(bytes20 walletPubKeyHash) view returns(bytes32) +func (_Bridge *BridgeCaller) WalletID(opts *bind.CallOpts, walletPubKeyHash [20]byte) ([32]byte, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "walletID", walletPubKeyHash) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// WalletID is a free data retrieval call binding the contract method 0x858c14bd. +// +// Solidity: function walletID(bytes20 walletPubKeyHash) view returns(bytes32) +func (_Bridge *BridgeSession) WalletID(walletPubKeyHash [20]byte) ([32]byte, error) { + return _Bridge.Contract.WalletID(&_Bridge.CallOpts, walletPubKeyHash) +} + +// WalletID is a free data retrieval call binding the contract method 0x858c14bd. +// +// Solidity: function walletID(bytes20 walletPubKeyHash) view returns(bytes32) +func (_Bridge *BridgeCallerSession) WalletID(walletPubKeyHash [20]byte) ([32]byte, error) { + return _Bridge.Contract.WalletID(&_Bridge.CallOpts, walletPubKeyHash) +} + // WalletParameters is a free data retrieval call binding the contract method 0x61ccf97a. // // Solidity: function walletParameters() view returns(uint32 walletCreationPeriod, uint64 walletCreationMinBtcBalance, uint64 walletCreationMaxBtcBalance, uint64 walletClosureMinBtcBalance, uint32 walletMaxAge, uint64 walletMaxBtcTransfer, uint32 walletClosingPeriod) @@ -1068,6 +1265,37 @@ func (_Bridge *BridgeCallerSession) WalletParameters() (struct { return _Bridge.Contract.WalletParameters(&_Bridge.CallOpts) } +// WalletPubKeyHashForWalletID is a free data retrieval call binding the contract method 0x9a4f2ea9. +// +// Solidity: function walletPubKeyHashForWalletID(bytes32 walletId) view returns(bytes20) +func (_Bridge *BridgeCaller) WalletPubKeyHashForWalletID(opts *bind.CallOpts, walletId [32]byte) ([20]byte, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "walletPubKeyHashForWalletID", walletId) + + if err != nil { + return *new([20]byte), err + } + + out0 := *abi.ConvertType(out[0], new([20]byte)).(*[20]byte) + + return out0, err + +} + +// WalletPubKeyHashForWalletID is a free data retrieval call binding the contract method 0x9a4f2ea9. +// +// Solidity: function walletPubKeyHashForWalletID(bytes32 walletId) view returns(bytes20) +func (_Bridge *BridgeSession) WalletPubKeyHashForWalletID(walletId [32]byte) ([20]byte, error) { + return _Bridge.Contract.WalletPubKeyHashForWalletID(&_Bridge.CallOpts, walletId) +} + +// WalletPubKeyHashForWalletID is a free data retrieval call binding the contract method 0x9a4f2ea9. +// +// Solidity: function walletPubKeyHashForWalletID(bytes32 walletId) view returns(bytes20) +func (_Bridge *BridgeCallerSession) WalletPubKeyHashForWalletID(walletId [32]byte) ([20]byte, error) { + return _Bridge.Contract.WalletPubKeyHashForWalletID(&_Bridge.CallOpts, walletId) +} + // Wallets is a free data retrieval call binding the contract method 0xe65e19d5. // // Solidity: function wallets(bytes20 walletPubKeyHash) view returns((bytes32,bytes32,uint64,uint32,uint32,uint32,uint32,uint8,bytes32)) @@ -1099,25 +1327,35 @@ func (_Bridge *BridgeCallerSession) Wallets(walletPubKeyHash [20]byte) (WalletsW return _Bridge.Contract.Wallets(&_Bridge.CallOpts, walletPubKeyHash) } -// EcdsaWalletCreatedCallback is a paid mutator transaction binding the contract method 0xa8fa0f42. +// WalletsByWalletID is a free data retrieval call binding the contract method 0xa9b2f9a3. // -// Solidity: function __ecdsaWalletCreatedCallback(bytes32 ecdsaWalletID, bytes32 publicKeyX, bytes32 publicKeyY) returns() -func (_Bridge *BridgeTransactor) EcdsaWalletCreatedCallback(opts *bind.TransactOpts, ecdsaWalletID [32]byte, publicKeyX [32]byte, publicKeyY [32]byte) (*types.Transaction, error) { - return _Bridge.contract.Transact(opts, "__ecdsaWalletCreatedCallback", ecdsaWalletID, publicKeyX, publicKeyY) +// Solidity: function walletsByWalletID(bytes32 walletId) view returns((bytes32,bytes32,uint64,uint32,uint32,uint32,uint32,uint8,bytes32)) +func (_Bridge *BridgeCaller) WalletsByWalletID(opts *bind.CallOpts, walletId [32]byte) (WalletsWallet, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "walletsByWalletID", walletId) + + if err != nil { + return *new(WalletsWallet), err + } + + out0 := *abi.ConvertType(out[0], new(WalletsWallet)).(*WalletsWallet) + + return out0, err + } -// EcdsaWalletCreatedCallback is a paid mutator transaction binding the contract method 0xa8fa0f42. +// WalletsByWalletID is a free data retrieval call binding the contract method 0xa9b2f9a3. // -// Solidity: function __ecdsaWalletCreatedCallback(bytes32 ecdsaWalletID, bytes32 publicKeyX, bytes32 publicKeyY) returns() -func (_Bridge *BridgeSession) EcdsaWalletCreatedCallback(ecdsaWalletID [32]byte, publicKeyX [32]byte, publicKeyY [32]byte) (*types.Transaction, error) { - return _Bridge.Contract.EcdsaWalletCreatedCallback(&_Bridge.TransactOpts, ecdsaWalletID, publicKeyX, publicKeyY) +// Solidity: function walletsByWalletID(bytes32 walletId) view returns((bytes32,bytes32,uint64,uint32,uint32,uint32,uint32,uint8,bytes32)) +func (_Bridge *BridgeSession) WalletsByWalletID(walletId [32]byte) (WalletsWallet, error) { + return _Bridge.Contract.WalletsByWalletID(&_Bridge.CallOpts, walletId) } -// EcdsaWalletCreatedCallback is a paid mutator transaction binding the contract method 0xa8fa0f42. +// WalletsByWalletID is a free data retrieval call binding the contract method 0xa9b2f9a3. // -// Solidity: function __ecdsaWalletCreatedCallback(bytes32 ecdsaWalletID, bytes32 publicKeyX, bytes32 publicKeyY) returns() -func (_Bridge *BridgeTransactorSession) EcdsaWalletCreatedCallback(ecdsaWalletID [32]byte, publicKeyX [32]byte, publicKeyY [32]byte) (*types.Transaction, error) { - return _Bridge.Contract.EcdsaWalletCreatedCallback(&_Bridge.TransactOpts, ecdsaWalletID, publicKeyX, publicKeyY) +// Solidity: function walletsByWalletID(bytes32 walletId) view returns((bytes32,bytes32,uint64,uint32,uint32,uint32,uint32,uint8,bytes32)) +func (_Bridge *BridgeCallerSession) WalletsByWalletID(walletId [32]byte) (WalletsWallet, error) { + return _Bridge.Contract.WalletsByWalletID(&_Bridge.CallOpts, walletId) } // EcdsaWalletHeartbeatFailedCallback is a paid mutator transaction binding the contract method 0x3dce9812. @@ -1141,46 +1379,25 @@ func (_Bridge *BridgeTransactorSession) EcdsaWalletHeartbeatFailedCallback(arg0 return _Bridge.Contract.EcdsaWalletHeartbeatFailedCallback(&_Bridge.TransactOpts, arg0, publicKeyX, publicKeyY) } -// DefeatFraudChallenge is a paid mutator transaction binding the contract method 0x77145f21. -// -// Solidity: function defeatFraudChallenge(bytes walletPublicKey, bytes preimage, bool witness) returns() -func (_Bridge *BridgeTransactor) DefeatFraudChallenge(opts *bind.TransactOpts, walletPublicKey []byte, preimage []byte, witness bool) (*types.Transaction, error) { - return _Bridge.contract.Transact(opts, "defeatFraudChallenge", walletPublicKey, preimage, witness) -} - -// DefeatFraudChallenge is a paid mutator transaction binding the contract method 0x77145f21. -// -// Solidity: function defeatFraudChallenge(bytes walletPublicKey, bytes preimage, bool witness) returns() -func (_Bridge *BridgeSession) DefeatFraudChallenge(walletPublicKey []byte, preimage []byte, witness bool) (*types.Transaction, error) { - return _Bridge.Contract.DefeatFraudChallenge(&_Bridge.TransactOpts, walletPublicKey, preimage, witness) -} - -// DefeatFraudChallenge is a paid mutator transaction binding the contract method 0x77145f21. +// FrostWalletCreatedCallback is a paid mutator transaction binding the contract method 0xd81c729e. // -// Solidity: function defeatFraudChallenge(bytes walletPublicKey, bytes preimage, bool witness) returns() -func (_Bridge *BridgeTransactorSession) DefeatFraudChallenge(walletPublicKey []byte, preimage []byte, witness bool) (*types.Transaction, error) { - return _Bridge.Contract.DefeatFraudChallenge(&_Bridge.TransactOpts, walletPublicKey, preimage, witness) +// Solidity: function __frostWalletCreatedCallback(bytes32 xOnlyOutputKey) returns() +func (_Bridge *BridgeTransactor) FrostWalletCreatedCallback(opts *bind.TransactOpts, xOnlyOutputKey [32]byte) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "__frostWalletCreatedCallback", xOnlyOutputKey) } -// DefeatFraudChallengeWithHeartbeat is a paid mutator transaction binding the contract method 0x0674f266. +// FrostWalletCreatedCallback is a paid mutator transaction binding the contract method 0xd81c729e. // -// Solidity: function defeatFraudChallengeWithHeartbeat(bytes walletPublicKey, bytes heartbeatMessage) returns() -func (_Bridge *BridgeTransactor) DefeatFraudChallengeWithHeartbeat(opts *bind.TransactOpts, walletPublicKey []byte, heartbeatMessage []byte) (*types.Transaction, error) { - return _Bridge.contract.Transact(opts, "defeatFraudChallengeWithHeartbeat", walletPublicKey, heartbeatMessage) +// Solidity: function __frostWalletCreatedCallback(bytes32 xOnlyOutputKey) returns() +func (_Bridge *BridgeSession) FrostWalletCreatedCallback(xOnlyOutputKey [32]byte) (*types.Transaction, error) { + return _Bridge.Contract.FrostWalletCreatedCallback(&_Bridge.TransactOpts, xOnlyOutputKey) } -// DefeatFraudChallengeWithHeartbeat is a paid mutator transaction binding the contract method 0x0674f266. +// FrostWalletCreatedCallback is a paid mutator transaction binding the contract method 0xd81c729e. // -// Solidity: function defeatFraudChallengeWithHeartbeat(bytes walletPublicKey, bytes heartbeatMessage) returns() -func (_Bridge *BridgeSession) DefeatFraudChallengeWithHeartbeat(walletPublicKey []byte, heartbeatMessage []byte) (*types.Transaction, error) { - return _Bridge.Contract.DefeatFraudChallengeWithHeartbeat(&_Bridge.TransactOpts, walletPublicKey, heartbeatMessage) -} - -// DefeatFraudChallengeWithHeartbeat is a paid mutator transaction binding the contract method 0x0674f266. -// -// Solidity: function defeatFraudChallengeWithHeartbeat(bytes walletPublicKey, bytes heartbeatMessage) returns() -func (_Bridge *BridgeTransactorSession) DefeatFraudChallengeWithHeartbeat(walletPublicKey []byte, heartbeatMessage []byte) (*types.Transaction, error) { - return _Bridge.Contract.DefeatFraudChallengeWithHeartbeat(&_Bridge.TransactOpts, walletPublicKey, heartbeatMessage) +// Solidity: function __frostWalletCreatedCallback(bytes32 xOnlyOutputKey) returns() +func (_Bridge *BridgeTransactorSession) FrostWalletCreatedCallback(xOnlyOutputKey [32]byte) (*types.Transaction, error) { + return _Bridge.Contract.FrostWalletCreatedCallback(&_Bridge.TransactOpts, xOnlyOutputKey) } // Initialize is a paid mutator transaction binding the contract method 0xd246ce16. @@ -1204,25 +1421,46 @@ func (_Bridge *BridgeTransactorSession) Initialize(_bank common.Address, _relay return _Bridge.Contract.Initialize(&_Bridge.TransactOpts, _bank, _relay, _treasury, _ecdsaWalletRegistry, _reimbursementPool, _txProofDifficultyFactor) } -// NotifyFraudChallengeDefeatTimeout is a paid mutator transaction binding the contract method 0x79fc4eb3. +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeTransactor) InitializeV2FixVaultZeroDeposit(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "initializeV2_FixVaultZeroDeposit") +} + +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeSession) InitializeV2FixVaultZeroDeposit() (*types.Transaction, error) { + return _Bridge.Contract.InitializeV2FixVaultZeroDeposit(&_Bridge.TransactOpts) +} + +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeTransactorSession) InitializeV2FixVaultZeroDeposit() (*types.Transaction, error) { + return _Bridge.Contract.InitializeV2FixVaultZeroDeposit(&_Bridge.TransactOpts) +} + +// MigrateLegacyFraudChallenges is a paid mutator transaction binding the contract method 0xfe491621. // -// Solidity: function notifyFraudChallengeDefeatTimeout(bytes walletPublicKey, uint32[] walletMembersIDs, bytes preimageSha256) returns() -func (_Bridge *BridgeTransactor) NotifyFraudChallengeDefeatTimeout(opts *bind.TransactOpts, walletPublicKey []byte, walletMembersIDs []uint32, preimageSha256 []byte) (*types.Transaction, error) { - return _Bridge.contract.Transact(opts, "notifyFraudChallengeDefeatTimeout", walletPublicKey, walletMembersIDs, preimageSha256) +// Solidity: function migrateLegacyFraudChallenges(uint8 routerKind, uint256[] challengeKeys) returns() +func (_Bridge *BridgeTransactor) MigrateLegacyFraudChallenges(opts *bind.TransactOpts, routerKind uint8, challengeKeys []*big.Int) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "migrateLegacyFraudChallenges", routerKind, challengeKeys) } -// NotifyFraudChallengeDefeatTimeout is a paid mutator transaction binding the contract method 0x79fc4eb3. +// MigrateLegacyFraudChallenges is a paid mutator transaction binding the contract method 0xfe491621. // -// Solidity: function notifyFraudChallengeDefeatTimeout(bytes walletPublicKey, uint32[] walletMembersIDs, bytes preimageSha256) returns() -func (_Bridge *BridgeSession) NotifyFraudChallengeDefeatTimeout(walletPublicKey []byte, walletMembersIDs []uint32, preimageSha256 []byte) (*types.Transaction, error) { - return _Bridge.Contract.NotifyFraudChallengeDefeatTimeout(&_Bridge.TransactOpts, walletPublicKey, walletMembersIDs, preimageSha256) +// Solidity: function migrateLegacyFraudChallenges(uint8 routerKind, uint256[] challengeKeys) returns() +func (_Bridge *BridgeSession) MigrateLegacyFraudChallenges(routerKind uint8, challengeKeys []*big.Int) (*types.Transaction, error) { + return _Bridge.Contract.MigrateLegacyFraudChallenges(&_Bridge.TransactOpts, routerKind, challengeKeys) } -// NotifyFraudChallengeDefeatTimeout is a paid mutator transaction binding the contract method 0x79fc4eb3. +// MigrateLegacyFraudChallenges is a paid mutator transaction binding the contract method 0xfe491621. // -// Solidity: function notifyFraudChallengeDefeatTimeout(bytes walletPublicKey, uint32[] walletMembersIDs, bytes preimageSha256) returns() -func (_Bridge *BridgeTransactorSession) NotifyFraudChallengeDefeatTimeout(walletPublicKey []byte, walletMembersIDs []uint32, preimageSha256 []byte) (*types.Transaction, error) { - return _Bridge.Contract.NotifyFraudChallengeDefeatTimeout(&_Bridge.TransactOpts, walletPublicKey, walletMembersIDs, preimageSha256) +// Solidity: function migrateLegacyFraudChallenges(uint8 routerKind, uint256[] challengeKeys) returns() +func (_Bridge *BridgeTransactorSession) MigrateLegacyFraudChallenges(routerKind uint8, challengeKeys []*big.Int) (*types.Transaction, error) { + return _Bridge.Contract.MigrateLegacyFraudChallenges(&_Bridge.TransactOpts, routerKind, challengeKeys) } // NotifyMovedFundsSweepTimeout is a paid mutator transaction binding the contract method 0x50aea15a. @@ -1456,6 +1694,27 @@ func (_Bridge *BridgeTransactorSession) ResetMovingFundsTimeout(walletPubKeyHash return _Bridge.Contract.ResetMovingFundsTimeout(&_Bridge.TransactOpts, walletPubKeyHash) } +// RetireEcdsa is a paid mutator transaction binding the contract method 0x0652611e. +// +// Solidity: function retireEcdsa() returns() +func (_Bridge *BridgeTransactor) RetireEcdsa(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "retireEcdsa") +} + +// RetireEcdsa is a paid mutator transaction binding the contract method 0x0652611e. +// +// Solidity: function retireEcdsa() returns() +func (_Bridge *BridgeSession) RetireEcdsa() (*types.Transaction, error) { + return _Bridge.Contract.RetireEcdsa(&_Bridge.TransactOpts) +} + +// RetireEcdsa is a paid mutator transaction binding the contract method 0x0652611e. +// +// Solidity: function retireEcdsa() returns() +func (_Bridge *BridgeTransactorSession) RetireEcdsa() (*types.Transaction, error) { + return _Bridge.Contract.RetireEcdsa(&_Bridge.TransactOpts) +} + // RevealDeposit is a paid mutator transaction binding the contract method 0xfca4ba4c. // // Solidity: function revealDeposit((bytes4,bytes,bytes,bytes4) fundingTx, (uint32,bytes8,bytes20,bytes20,bytes4,address) reveal) returns() @@ -1498,6 +1757,153 @@ func (_Bridge *BridgeTransactorSession) RevealDepositWithExtraData(fundingTx Bit return _Bridge.Contract.RevealDepositWithExtraData(&_Bridge.TransactOpts, fundingTx, reveal, extraData) } +// RevealTaprootDeposit is a paid mutator transaction binding the contract method 0xbbbafefa. +// +// Solidity: function revealTaprootDeposit((bytes4,bytes,bytes,bytes4) fundingTx, (uint32,bytes8,bytes20,bytes32,bytes20,bytes32,bytes4,address) reveal) returns() +func (_Bridge *BridgeTransactor) RevealTaprootDeposit(opts *bind.TransactOpts, fundingTx BitcoinTxInfo, reveal DepositTaprootDepositRevealInfo) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "revealTaprootDeposit", fundingTx, reveal) +} + +// RevealTaprootDeposit is a paid mutator transaction binding the contract method 0xbbbafefa. +// +// Solidity: function revealTaprootDeposit((bytes4,bytes,bytes,bytes4) fundingTx, (uint32,bytes8,bytes20,bytes32,bytes20,bytes32,bytes4,address) reveal) returns() +func (_Bridge *BridgeSession) RevealTaprootDeposit(fundingTx BitcoinTxInfo, reveal DepositTaprootDepositRevealInfo) (*types.Transaction, error) { + return _Bridge.Contract.RevealTaprootDeposit(&_Bridge.TransactOpts, fundingTx, reveal) +} + +// RevealTaprootDeposit is a paid mutator transaction binding the contract method 0xbbbafefa. +// +// Solidity: function revealTaprootDeposit((bytes4,bytes,bytes,bytes4) fundingTx, (uint32,bytes8,bytes20,bytes32,bytes20,bytes32,bytes4,address) reveal) returns() +func (_Bridge *BridgeTransactorSession) RevealTaprootDeposit(fundingTx BitcoinTxInfo, reveal DepositTaprootDepositRevealInfo) (*types.Transaction, error) { + return _Bridge.Contract.RevealTaprootDeposit(&_Bridge.TransactOpts, fundingTx, reveal) +} + +// RevealTaprootDepositWithExtraData is a paid mutator transaction binding the contract method 0xa97c9f34. +// +// Solidity: function revealTaprootDepositWithExtraData((bytes4,bytes,bytes,bytes4) fundingTx, (uint32,bytes8,bytes20,bytes32,bytes20,bytes32,bytes4,address) reveal, bytes32 extraData) returns() +func (_Bridge *BridgeTransactor) RevealTaprootDepositWithExtraData(opts *bind.TransactOpts, fundingTx BitcoinTxInfo, reveal DepositTaprootDepositRevealInfo, extraData [32]byte) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "revealTaprootDepositWithExtraData", fundingTx, reveal, extraData) +} + +// RevealTaprootDepositWithExtraData is a paid mutator transaction binding the contract method 0xa97c9f34. +// +// Solidity: function revealTaprootDepositWithExtraData((bytes4,bytes,bytes,bytes4) fundingTx, (uint32,bytes8,bytes20,bytes32,bytes20,bytes32,bytes4,address) reveal, bytes32 extraData) returns() +func (_Bridge *BridgeSession) RevealTaprootDepositWithExtraData(fundingTx BitcoinTxInfo, reveal DepositTaprootDepositRevealInfo, extraData [32]byte) (*types.Transaction, error) { + return _Bridge.Contract.RevealTaprootDepositWithExtraData(&_Bridge.TransactOpts, fundingTx, reveal, extraData) +} + +// RevealTaprootDepositWithExtraData is a paid mutator transaction binding the contract method 0xa97c9f34. +// +// Solidity: function revealTaprootDepositWithExtraData((bytes4,bytes,bytes,bytes4) fundingTx, (uint32,bytes8,bytes20,bytes32,bytes20,bytes32,bytes4,address) reveal, bytes32 extraData) returns() +func (_Bridge *BridgeTransactorSession) RevealTaprootDepositWithExtraData(fundingTx BitcoinTxInfo, reveal DepositTaprootDepositRevealInfo, extraData [32]byte) (*types.Transaction, error) { + return _Bridge.Contract.RevealTaprootDepositWithExtraData(&_Bridge.TransactOpts, fundingTx, reveal, extraData) +} + +// SetEcdsaFraudRouter is a paid mutator transaction binding the contract method 0xba863979. +// +// Solidity: function setEcdsaFraudRouter(address ecdsaFraudRouter) returns() +func (_Bridge *BridgeTransactor) SetEcdsaFraudRouter(opts *bind.TransactOpts, ecdsaFraudRouter common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setEcdsaFraudRouter", ecdsaFraudRouter) +} + +// SetEcdsaFraudRouter is a paid mutator transaction binding the contract method 0xba863979. +// +// Solidity: function setEcdsaFraudRouter(address ecdsaFraudRouter) returns() +func (_Bridge *BridgeSession) SetEcdsaFraudRouter(ecdsaFraudRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetEcdsaFraudRouter(&_Bridge.TransactOpts, ecdsaFraudRouter) +} + +// SetEcdsaFraudRouter is a paid mutator transaction binding the contract method 0xba863979. +// +// Solidity: function setEcdsaFraudRouter(address ecdsaFraudRouter) returns() +func (_Bridge *BridgeTransactorSession) SetEcdsaFraudRouter(ecdsaFraudRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetEcdsaFraudRouter(&_Bridge.TransactOpts, ecdsaFraudRouter) +} + +// SetFrostWalletRegistry is a paid mutator transaction binding the contract method 0x07fe5dad. +// +// Solidity: function setFrostWalletRegistry(address frostWalletRegistry) returns() +func (_Bridge *BridgeTransactor) SetFrostWalletRegistry(opts *bind.TransactOpts, frostWalletRegistry common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setFrostWalletRegistry", frostWalletRegistry) +} + +// SetFrostWalletRegistry is a paid mutator transaction binding the contract method 0x07fe5dad. +// +// Solidity: function setFrostWalletRegistry(address frostWalletRegistry) returns() +func (_Bridge *BridgeSession) SetFrostWalletRegistry(frostWalletRegistry common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetFrostWalletRegistry(&_Bridge.TransactOpts, frostWalletRegistry) +} + +// SetFrostWalletRegistry is a paid mutator transaction binding the contract method 0x07fe5dad. +// +// Solidity: function setFrostWalletRegistry(address frostWalletRegistry) returns() +func (_Bridge *BridgeTransactorSession) SetFrostWalletRegistry(frostWalletRegistry common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetFrostWalletRegistry(&_Bridge.TransactOpts, frostWalletRegistry) +} + +// SetLifecycleRouter is a paid mutator transaction binding the contract method 0xdf5efac8. +// +// Solidity: function setLifecycleRouter(address lifecycleRouter) returns() +func (_Bridge *BridgeTransactor) SetLifecycleRouter(opts *bind.TransactOpts, lifecycleRouter common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setLifecycleRouter", lifecycleRouter) +} + +// SetLifecycleRouter is a paid mutator transaction binding the contract method 0xdf5efac8. +// +// Solidity: function setLifecycleRouter(address lifecycleRouter) returns() +func (_Bridge *BridgeSession) SetLifecycleRouter(lifecycleRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetLifecycleRouter(&_Bridge.TransactOpts, lifecycleRouter) +} + +// SetLifecycleRouter is a paid mutator transaction binding the contract method 0xdf5efac8. +// +// Solidity: function setLifecycleRouter(address lifecycleRouter) returns() +func (_Bridge *BridgeTransactorSession) SetLifecycleRouter(lifecycleRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetLifecycleRouter(&_Bridge.TransactOpts, lifecycleRouter) +} + +// SetP2TRFraudRouter is a paid mutator transaction binding the contract method 0x6247cf16. +// +// Solidity: function setP2TRFraudRouter(address p2trFraudRouter) returns() +func (_Bridge *BridgeTransactor) SetP2TRFraudRouter(opts *bind.TransactOpts, p2trFraudRouter common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setP2TRFraudRouter", p2trFraudRouter) +} + +// SetP2TRFraudRouter is a paid mutator transaction binding the contract method 0x6247cf16. +// +// Solidity: function setP2TRFraudRouter(address p2trFraudRouter) returns() +func (_Bridge *BridgeSession) SetP2TRFraudRouter(p2trFraudRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetP2TRFraudRouter(&_Bridge.TransactOpts, p2trFraudRouter) +} + +// SetP2TRFraudRouter is a paid mutator transaction binding the contract method 0x6247cf16. +// +// Solidity: function setP2TRFraudRouter(address p2trFraudRouter) returns() +func (_Bridge *BridgeTransactorSession) SetP2TRFraudRouter(p2trFraudRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetP2TRFraudRouter(&_Bridge.TransactOpts, p2trFraudRouter) +} + +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeTransactor) SetRebateStaking(opts *bind.TransactOpts, rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setRebateStaking", rebateStaking) +} + +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeSession) SetRebateStaking(rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetRebateStaking(&_Bridge.TransactOpts, rebateStaking) +} + +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeTransactorSession) SetRebateStaking(rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetRebateStaking(&_Bridge.TransactOpts, rebateStaking) +} + // SetRedemptionWatchtower is a paid mutator transaction binding the contract method 0xbe26ebad. // // Solidity: function setRedemptionWatchtower(address redemptionWatchtower) returns() @@ -1561,52 +1967,73 @@ func (_Bridge *BridgeTransactorSession) SetVaultStatus(vault common.Address, isT return _Bridge.Contract.SetVaultStatus(&_Bridge.TransactOpts, vault, isTrusted) } -// SubmitDepositSweepProof is a paid mutator transaction binding the contract method 0xbd150131. +// SlashWalletForFraud is a paid mutator transaction binding the contract method 0x3f5dfabb. // -// Solidity: function submitDepositSweepProof((bytes4,bytes,bytes,bytes4) sweepTx, (bytes,uint256,bytes,bytes32,bytes) sweepProof, (bytes32,uint32,uint64) mainUtxo, address vault) returns() -func (_Bridge *BridgeTransactor) SubmitDepositSweepProof(opts *bind.TransactOpts, sweepTx BitcoinTxInfo, sweepProof BitcoinTxProof, mainUtxo BitcoinTxUTXO, vault common.Address) (*types.Transaction, error) { - return _Bridge.contract.Transact(opts, "submitDepositSweepProof", sweepTx, sweepProof, mainUtxo, vault) +// Solidity: function slashWalletForFraud(bytes20 walletPubKeyHash, uint32[] walletMembersIDs, address challenger) returns() +func (_Bridge *BridgeTransactor) SlashWalletForFraud(opts *bind.TransactOpts, walletPubKeyHash [20]byte, walletMembersIDs []uint32, challenger common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "slashWalletForFraud", walletPubKeyHash, walletMembersIDs, challenger) } -// SubmitDepositSweepProof is a paid mutator transaction binding the contract method 0xbd150131. +// SlashWalletForFraud is a paid mutator transaction binding the contract method 0x3f5dfabb. // -// Solidity: function submitDepositSweepProof((bytes4,bytes,bytes,bytes4) sweepTx, (bytes,uint256,bytes,bytes32,bytes) sweepProof, (bytes32,uint32,uint64) mainUtxo, address vault) returns() -func (_Bridge *BridgeSession) SubmitDepositSweepProof(sweepTx BitcoinTxInfo, sweepProof BitcoinTxProof, mainUtxo BitcoinTxUTXO, vault common.Address) (*types.Transaction, error) { - return _Bridge.Contract.SubmitDepositSweepProof(&_Bridge.TransactOpts, sweepTx, sweepProof, mainUtxo, vault) +// Solidity: function slashWalletForFraud(bytes20 walletPubKeyHash, uint32[] walletMembersIDs, address challenger) returns() +func (_Bridge *BridgeSession) SlashWalletForFraud(walletPubKeyHash [20]byte, walletMembersIDs []uint32, challenger common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SlashWalletForFraud(&_Bridge.TransactOpts, walletPubKeyHash, walletMembersIDs, challenger) } -// SubmitDepositSweepProof is a paid mutator transaction binding the contract method 0xbd150131. +// SlashWalletForFraud is a paid mutator transaction binding the contract method 0x3f5dfabb. // -// Solidity: function submitDepositSweepProof((bytes4,bytes,bytes,bytes4) sweepTx, (bytes,uint256,bytes,bytes32,bytes) sweepProof, (bytes32,uint32,uint64) mainUtxo, address vault) returns() -func (_Bridge *BridgeTransactorSession) SubmitDepositSweepProof(sweepTx BitcoinTxInfo, sweepProof BitcoinTxProof, mainUtxo BitcoinTxUTXO, vault common.Address) (*types.Transaction, error) { - return _Bridge.Contract.SubmitDepositSweepProof(&_Bridge.TransactOpts, sweepTx, sweepProof, mainUtxo, vault) +// Solidity: function slashWalletForFraud(bytes20 walletPubKeyHash, uint32[] walletMembersIDs, address challenger) returns() +func (_Bridge *BridgeTransactorSession) SlashWalletForFraud(walletPubKeyHash [20]byte, walletMembersIDs []uint32, challenger common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SlashWalletForFraud(&_Bridge.TransactOpts, walletPubKeyHash, walletMembersIDs, challenger) } -// SubmitFraudChallenge is a paid mutator transaction binding the contract method 0x685ce1b1. +// SlashWalletForP2TRFraud is a paid mutator transaction binding the contract method 0xc823b5cf. // -// Solidity: function submitFraudChallenge(bytes walletPublicKey, bytes preimageSha256, (bytes32,bytes32,uint8) signature) payable returns() -func (_Bridge *BridgeTransactor) SubmitFraudChallenge(opts *bind.TransactOpts, walletPublicKey []byte, preimageSha256 []byte, signature BitcoinTxRSVSignature) (*types.Transaction, error) { - return _Bridge.contract.Transact(opts, "submitFraudChallenge", walletPublicKey, preimageSha256, signature) +// Solidity: function slashWalletForP2TRFraud(bytes20 walletPubKeyHash, uint32[] walletMembersIDs, address challenger) returns() +func (_Bridge *BridgeTransactor) SlashWalletForP2TRFraud(opts *bind.TransactOpts, walletPubKeyHash [20]byte, walletMembersIDs []uint32, challenger common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "slashWalletForP2TRFraud", walletPubKeyHash, walletMembersIDs, challenger) } -// SubmitFraudChallenge is a paid mutator transaction binding the contract method 0x685ce1b1. +// SlashWalletForP2TRFraud is a paid mutator transaction binding the contract method 0xc823b5cf. // -// Solidity: function submitFraudChallenge(bytes walletPublicKey, bytes preimageSha256, (bytes32,bytes32,uint8) signature) payable returns() -func (_Bridge *BridgeSession) SubmitFraudChallenge(walletPublicKey []byte, preimageSha256 []byte, signature BitcoinTxRSVSignature) (*types.Transaction, error) { - return _Bridge.Contract.SubmitFraudChallenge(&_Bridge.TransactOpts, walletPublicKey, preimageSha256, signature) +// Solidity: function slashWalletForP2TRFraud(bytes20 walletPubKeyHash, uint32[] walletMembersIDs, address challenger) returns() +func (_Bridge *BridgeSession) SlashWalletForP2TRFraud(walletPubKeyHash [20]byte, walletMembersIDs []uint32, challenger common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SlashWalletForP2TRFraud(&_Bridge.TransactOpts, walletPubKeyHash, walletMembersIDs, challenger) } -// SubmitFraudChallenge is a paid mutator transaction binding the contract method 0x685ce1b1. +// SlashWalletForP2TRFraud is a paid mutator transaction binding the contract method 0xc823b5cf. // -// Solidity: function submitFraudChallenge(bytes walletPublicKey, bytes preimageSha256, (bytes32,bytes32,uint8) signature) payable returns() -func (_Bridge *BridgeTransactorSession) SubmitFraudChallenge(walletPublicKey []byte, preimageSha256 []byte, signature BitcoinTxRSVSignature) (*types.Transaction, error) { - return _Bridge.Contract.SubmitFraudChallenge(&_Bridge.TransactOpts, walletPublicKey, preimageSha256, signature) +// Solidity: function slashWalletForP2TRFraud(bytes20 walletPubKeyHash, uint32[] walletMembersIDs, address challenger) returns() +func (_Bridge *BridgeTransactorSession) SlashWalletForP2TRFraud(walletPubKeyHash [20]byte, walletMembersIDs []uint32, challenger common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SlashWalletForP2TRFraud(&_Bridge.TransactOpts, walletPubKeyHash, walletMembersIDs, challenger) } -// SubmitMovedFundsSweepProof is a paid mutator transaction binding the contract method 0x9821c38b. +// SubmitDepositSweepProof is a paid mutator transaction binding the contract method 0xbd150131. // -// Solidity: function submitMovedFundsSweepProof((bytes4,bytes,bytes,bytes4) sweepTx, (bytes,uint256,bytes,bytes32,bytes) sweepProof, (bytes32,uint32,uint64) mainUtxo) returns() -func (_Bridge *BridgeTransactor) SubmitMovedFundsSweepProof(opts *bind.TransactOpts, sweepTx BitcoinTxInfo, sweepProof BitcoinTxProof, mainUtxo BitcoinTxUTXO) (*types.Transaction, error) { +// Solidity: function submitDepositSweepProof((bytes4,bytes,bytes,bytes4) sweepTx, (bytes,uint256,bytes,bytes32,bytes) sweepProof, (bytes32,uint32,uint64) mainUtxo, address vault) returns() +func (_Bridge *BridgeTransactor) SubmitDepositSweepProof(opts *bind.TransactOpts, sweepTx BitcoinTxInfo, sweepProof BitcoinTxProof, mainUtxo BitcoinTxUTXO, vault common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "submitDepositSweepProof", sweepTx, sweepProof, mainUtxo, vault) +} + +// SubmitDepositSweepProof is a paid mutator transaction binding the contract method 0xbd150131. +// +// Solidity: function submitDepositSweepProof((bytes4,bytes,bytes,bytes4) sweepTx, (bytes,uint256,bytes,bytes32,bytes) sweepProof, (bytes32,uint32,uint64) mainUtxo, address vault) returns() +func (_Bridge *BridgeSession) SubmitDepositSweepProof(sweepTx BitcoinTxInfo, sweepProof BitcoinTxProof, mainUtxo BitcoinTxUTXO, vault common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SubmitDepositSweepProof(&_Bridge.TransactOpts, sweepTx, sweepProof, mainUtxo, vault) +} + +// SubmitDepositSweepProof is a paid mutator transaction binding the contract method 0xbd150131. +// +// Solidity: function submitDepositSweepProof((bytes4,bytes,bytes,bytes4) sweepTx, (bytes,uint256,bytes,bytes32,bytes) sweepProof, (bytes32,uint32,uint64) mainUtxo, address vault) returns() +func (_Bridge *BridgeTransactorSession) SubmitDepositSweepProof(sweepTx BitcoinTxInfo, sweepProof BitcoinTxProof, mainUtxo BitcoinTxUTXO, vault common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SubmitDepositSweepProof(&_Bridge.TransactOpts, sweepTx, sweepProof, mainUtxo, vault) +} + +// SubmitMovedFundsSweepProof is a paid mutator transaction binding the contract method 0x9821c38b. +// +// Solidity: function submitMovedFundsSweepProof((bytes4,bytes,bytes,bytes4) sweepTx, (bytes,uint256,bytes,bytes32,bytes) sweepProof, (bytes32,uint32,uint64) mainUtxo) returns() +func (_Bridge *BridgeTransactor) SubmitMovedFundsSweepProof(opts *bind.TransactOpts, sweepTx BitcoinTxInfo, sweepProof BitcoinTxProof, mainUtxo BitcoinTxUTXO) (*types.Transaction, error) { return _Bridge.contract.Transact(opts, "submitMovedFundsSweepProof", sweepTx, sweepProof, mainUtxo) } @@ -2133,9 +2560,9 @@ func (_Bridge *BridgeFilterer) ParseDepositRevealed(log types.Log) (*BridgeDepos return event, nil } -// BridgeDepositsSweptIterator is returned from FilterDepositsSwept and is used to iterate over the raw logs and unpacked data for DepositsSwept events raised by the Bridge contract. -type BridgeDepositsSweptIterator struct { - Event *BridgeDepositsSwept // Event containing the contract specifics and raw log +// BridgeDepositVaultFixedIterator is returned from FilterDepositVaultFixed and is used to iterate over the raw logs and unpacked data for DepositVaultFixed events raised by the Bridge contract. +type BridgeDepositVaultFixedIterator struct { + Event *BridgeDepositVaultFixed // Event containing the contract specifics and raw log contract *bind.BoundContract // Generic contract to use for unpacking event data event string // Event name to use for unpacking event data @@ -2149,7 +2576,7 @@ type BridgeDepositsSweptIterator struct { // Next advances the iterator to the subsequent event, returning whether there // are any more events found. In case of a retrieval or parsing error, false is // returned and Error() can be queried for the exact failure. -func (it *BridgeDepositsSweptIterator) Next() bool { +func (it *BridgeDepositVaultFixedIterator) Next() bool { // If the iterator failed, stop iterating if it.fail != nil { return false @@ -2158,7 +2585,7 @@ func (it *BridgeDepositsSweptIterator) Next() bool { if it.done { select { case log := <-it.logs: - it.Event = new(BridgeDepositsSwept) + it.Event = new(BridgeDepositVaultFixed) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -2173,7 +2600,7 @@ func (it *BridgeDepositsSweptIterator) Next() bool { // Iterator still in progress, wait for either a data or an error event select { case log := <-it.logs: - it.Event = new(BridgeDepositsSwept) + it.Event = new(BridgeDepositVaultFixed) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -2189,42 +2616,52 @@ func (it *BridgeDepositsSweptIterator) Next() bool { } // Error returns any retrieval or parsing error occurred during filtering. -func (it *BridgeDepositsSweptIterator) Error() error { +func (it *BridgeDepositVaultFixedIterator) Error() error { return it.fail } // Close terminates the iteration process, releasing any pending underlying // resources. -func (it *BridgeDepositsSweptIterator) Close() error { +func (it *BridgeDepositVaultFixedIterator) Close() error { it.sub.Unsubscribe() return nil } -// BridgeDepositsSwept represents a DepositsSwept event raised by the Bridge contract. -type BridgeDepositsSwept struct { - WalletPubKeyHash [20]byte - SweepTxHash [32]byte - Raw types.Log // Blockchain specific contextual infos +// BridgeDepositVaultFixed represents a DepositVaultFixed event raised by the Bridge contract. +type BridgeDepositVaultFixed struct { + DepositKey *big.Int + NewVault common.Address + Raw types.Log // Blockchain specific contextual infos } -// FilterDepositsSwept is a free log retrieval operation binding the contract event 0xe50ffdcc0a5f2c1ede5c122b9414ffd7b2c6bc870d2d775194049dc30da95e6a. +// FilterDepositVaultFixed is a free log retrieval operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. // -// Solidity: event DepositsSwept(bytes20 walletPubKeyHash, bytes32 sweepTxHash) -func (_Bridge *BridgeFilterer) FilterDepositsSwept(opts *bind.FilterOpts) (*BridgeDepositsSweptIterator, error) { +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) FilterDepositVaultFixed(opts *bind.FilterOpts, depositKey []*big.Int) (*BridgeDepositVaultFixedIterator, error) { - logs, sub, err := _Bridge.contract.FilterLogs(opts, "DepositsSwept") + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "DepositVaultFixed", depositKeyRule) if err != nil { return nil, err } - return &BridgeDepositsSweptIterator{contract: _Bridge.contract, event: "DepositsSwept", logs: logs, sub: sub}, nil + return &BridgeDepositVaultFixedIterator{contract: _Bridge.contract, event: "DepositVaultFixed", logs: logs, sub: sub}, nil } -// WatchDepositsSwept is a free log subscription operation binding the contract event 0xe50ffdcc0a5f2c1ede5c122b9414ffd7b2c6bc870d2d775194049dc30da95e6a. +// WatchDepositVaultFixed is a free log subscription operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. // -// Solidity: event DepositsSwept(bytes20 walletPubKeyHash, bytes32 sweepTxHash) -func (_Bridge *BridgeFilterer) WatchDepositsSwept(opts *bind.WatchOpts, sink chan<- *BridgeDepositsSwept) (event.Subscription, error) { +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) WatchDepositVaultFixed(opts *bind.WatchOpts, sink chan<- *BridgeDepositVaultFixed, depositKey []*big.Int) (event.Subscription, error) { - logs, sub, err := _Bridge.contract.WatchLogs(opts, "DepositsSwept") + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "DepositVaultFixed", depositKeyRule) if err != nil { return nil, err } @@ -2234,8 +2671,8 @@ func (_Bridge *BridgeFilterer) WatchDepositsSwept(opts *bind.WatchOpts, sink cha select { case log := <-logs: // New log arrived, parse the event and forward to the user - event := new(BridgeDepositsSwept) - if err := _Bridge.contract.UnpackLog(event, "DepositsSwept", log); err != nil { + event := new(BridgeDepositVaultFixed) + if err := _Bridge.contract.UnpackLog(event, "DepositVaultFixed", log); err != nil { return err } event.Raw = log @@ -2256,21 +2693,21 @@ func (_Bridge *BridgeFilterer) WatchDepositsSwept(opts *bind.WatchOpts, sink cha }), nil } -// ParseDepositsSwept is a log parse operation binding the contract event 0xe50ffdcc0a5f2c1ede5c122b9414ffd7b2c6bc870d2d775194049dc30da95e6a. +// ParseDepositVaultFixed is a log parse operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. // -// Solidity: event DepositsSwept(bytes20 walletPubKeyHash, bytes32 sweepTxHash) -func (_Bridge *BridgeFilterer) ParseDepositsSwept(log types.Log) (*BridgeDepositsSwept, error) { - event := new(BridgeDepositsSwept) - if err := _Bridge.contract.UnpackLog(event, "DepositsSwept", log); err != nil { +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) ParseDepositVaultFixed(log types.Log) (*BridgeDepositVaultFixed, error) { + event := new(BridgeDepositVaultFixed) + if err := _Bridge.contract.UnpackLog(event, "DepositVaultFixed", log); err != nil { return nil, err } event.Raw = log return event, nil } -// BridgeFraudChallengeDefeatTimedOutIterator is returned from FilterFraudChallengeDefeatTimedOut and is used to iterate over the raw logs and unpacked data for FraudChallengeDefeatTimedOut events raised by the Bridge contract. -type BridgeFraudChallengeDefeatTimedOutIterator struct { - Event *BridgeFraudChallengeDefeatTimedOut // Event containing the contract specifics and raw log +// BridgeDepositsSweptIterator is returned from FilterDepositsSwept and is used to iterate over the raw logs and unpacked data for DepositsSwept events raised by the Bridge contract. +type BridgeDepositsSweptIterator struct { + Event *BridgeDepositsSwept // Event containing the contract specifics and raw log contract *bind.BoundContract // Generic contract to use for unpacking event data event string // Event name to use for unpacking event data @@ -2284,7 +2721,7 @@ type BridgeFraudChallengeDefeatTimedOutIterator struct { // Next advances the iterator to the subsequent event, returning whether there // are any more events found. In case of a retrieval or parsing error, false is // returned and Error() can be queried for the exact failure. -func (it *BridgeFraudChallengeDefeatTimedOutIterator) Next() bool { +func (it *BridgeDepositsSweptIterator) Next() bool { // If the iterator failed, stop iterating if it.fail != nil { return false @@ -2293,7 +2730,7 @@ func (it *BridgeFraudChallengeDefeatTimedOutIterator) Next() bool { if it.done { select { case log := <-it.logs: - it.Event = new(BridgeFraudChallengeDefeatTimedOut) + it.Event = new(BridgeDepositsSwept) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -2308,7 +2745,7 @@ func (it *BridgeFraudChallengeDefeatTimedOutIterator) Next() bool { // Iterator still in progress, wait for either a data or an error event select { case log := <-it.logs: - it.Event = new(BridgeFraudChallengeDefeatTimedOut) + it.Event = new(BridgeDepositsSwept) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -2324,52 +2761,42 @@ func (it *BridgeFraudChallengeDefeatTimedOutIterator) Next() bool { } // Error returns any retrieval or parsing error occurred during filtering. -func (it *BridgeFraudChallengeDefeatTimedOutIterator) Error() error { +func (it *BridgeDepositsSweptIterator) Error() error { return it.fail } // Close terminates the iteration process, releasing any pending underlying // resources. -func (it *BridgeFraudChallengeDefeatTimedOutIterator) Close() error { +func (it *BridgeDepositsSweptIterator) Close() error { it.sub.Unsubscribe() return nil } -// BridgeFraudChallengeDefeatTimedOut represents a FraudChallengeDefeatTimedOut event raised by the Bridge contract. -type BridgeFraudChallengeDefeatTimedOut struct { +// BridgeDepositsSwept represents a DepositsSwept event raised by the Bridge contract. +type BridgeDepositsSwept struct { WalletPubKeyHash [20]byte - Sighash [32]byte + SweepTxHash [32]byte Raw types.Log // Blockchain specific contextual infos } -// FilterFraudChallengeDefeatTimedOut is a free log retrieval operation binding the contract event 0x635230b60143449f10a365568e2bd95e3e8aaed03855631722941c2bad634b77. +// FilterDepositsSwept is a free log retrieval operation binding the contract event 0xe50ffdcc0a5f2c1ede5c122b9414ffd7b2c6bc870d2d775194049dc30da95e6a. // -// Solidity: event FraudChallengeDefeatTimedOut(bytes20 indexed walletPubKeyHash, bytes32 sighash) -func (_Bridge *BridgeFilterer) FilterFraudChallengeDefeatTimedOut(opts *bind.FilterOpts, walletPubKeyHash [][20]byte) (*BridgeFraudChallengeDefeatTimedOutIterator, error) { - - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) - } +// Solidity: event DepositsSwept(bytes20 walletPubKeyHash, bytes32 sweepTxHash) +func (_Bridge *BridgeFilterer) FilterDepositsSwept(opts *bind.FilterOpts) (*BridgeDepositsSweptIterator, error) { - logs, sub, err := _Bridge.contract.FilterLogs(opts, "FraudChallengeDefeatTimedOut", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.FilterLogs(opts, "DepositsSwept") if err != nil { return nil, err } - return &BridgeFraudChallengeDefeatTimedOutIterator{contract: _Bridge.contract, event: "FraudChallengeDefeatTimedOut", logs: logs, sub: sub}, nil + return &BridgeDepositsSweptIterator{contract: _Bridge.contract, event: "DepositsSwept", logs: logs, sub: sub}, nil } -// WatchFraudChallengeDefeatTimedOut is a free log subscription operation binding the contract event 0x635230b60143449f10a365568e2bd95e3e8aaed03855631722941c2bad634b77. +// WatchDepositsSwept is a free log subscription operation binding the contract event 0xe50ffdcc0a5f2c1ede5c122b9414ffd7b2c6bc870d2d775194049dc30da95e6a. // -// Solidity: event FraudChallengeDefeatTimedOut(bytes20 indexed walletPubKeyHash, bytes32 sighash) -func (_Bridge *BridgeFilterer) WatchFraudChallengeDefeatTimedOut(opts *bind.WatchOpts, sink chan<- *BridgeFraudChallengeDefeatTimedOut, walletPubKeyHash [][20]byte) (event.Subscription, error) { - - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) - } +// Solidity: event DepositsSwept(bytes20 walletPubKeyHash, bytes32 sweepTxHash) +func (_Bridge *BridgeFilterer) WatchDepositsSwept(opts *bind.WatchOpts, sink chan<- *BridgeDepositsSwept) (event.Subscription, error) { - logs, sub, err := _Bridge.contract.WatchLogs(opts, "FraudChallengeDefeatTimedOut", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.WatchLogs(opts, "DepositsSwept") if err != nil { return nil, err } @@ -2379,8 +2806,8 @@ func (_Bridge *BridgeFilterer) WatchFraudChallengeDefeatTimedOut(opts *bind.Watc select { case log := <-logs: // New log arrived, parse the event and forward to the user - event := new(BridgeFraudChallengeDefeatTimedOut) - if err := _Bridge.contract.UnpackLog(event, "FraudChallengeDefeatTimedOut", log); err != nil { + event := new(BridgeDepositsSwept) + if err := _Bridge.contract.UnpackLog(event, "DepositsSwept", log); err != nil { return err } event.Raw = log @@ -2401,21 +2828,21 @@ func (_Bridge *BridgeFilterer) WatchFraudChallengeDefeatTimedOut(opts *bind.Watc }), nil } -// ParseFraudChallengeDefeatTimedOut is a log parse operation binding the contract event 0x635230b60143449f10a365568e2bd95e3e8aaed03855631722941c2bad634b77. +// ParseDepositsSwept is a log parse operation binding the contract event 0xe50ffdcc0a5f2c1ede5c122b9414ffd7b2c6bc870d2d775194049dc30da95e6a. // -// Solidity: event FraudChallengeDefeatTimedOut(bytes20 indexed walletPubKeyHash, bytes32 sighash) -func (_Bridge *BridgeFilterer) ParseFraudChallengeDefeatTimedOut(log types.Log) (*BridgeFraudChallengeDefeatTimedOut, error) { - event := new(BridgeFraudChallengeDefeatTimedOut) - if err := _Bridge.contract.UnpackLog(event, "FraudChallengeDefeatTimedOut", log); err != nil { +// Solidity: event DepositsSwept(bytes20 walletPubKeyHash, bytes32 sweepTxHash) +func (_Bridge *BridgeFilterer) ParseDepositsSwept(log types.Log) (*BridgeDepositsSwept, error) { + event := new(BridgeDepositsSwept) + if err := _Bridge.contract.UnpackLog(event, "DepositsSwept", log); err != nil { return nil, err } event.Raw = log return event, nil } -// BridgeFraudChallengeDefeatedIterator is returned from FilterFraudChallengeDefeated and is used to iterate over the raw logs and unpacked data for FraudChallengeDefeated events raised by the Bridge contract. -type BridgeFraudChallengeDefeatedIterator struct { - Event *BridgeFraudChallengeDefeated // Event containing the contract specifics and raw log +// BridgeEcdsaFraudRouterSetIterator is returned from FilterEcdsaFraudRouterSet and is used to iterate over the raw logs and unpacked data for EcdsaFraudRouterSet events raised by the Bridge contract. +type BridgeEcdsaFraudRouterSetIterator struct { + Event *BridgeEcdsaFraudRouterSet // Event containing the contract specifics and raw log contract *bind.BoundContract // Generic contract to use for unpacking event data event string // Event name to use for unpacking event data @@ -2429,7 +2856,7 @@ type BridgeFraudChallengeDefeatedIterator struct { // Next advances the iterator to the subsequent event, returning whether there // are any more events found. In case of a retrieval or parsing error, false is // returned and Error() can be queried for the exact failure. -func (it *BridgeFraudChallengeDefeatedIterator) Next() bool { +func (it *BridgeEcdsaFraudRouterSetIterator) Next() bool { // If the iterator failed, stop iterating if it.fail != nil { return false @@ -2438,7 +2865,7 @@ func (it *BridgeFraudChallengeDefeatedIterator) Next() bool { if it.done { select { case log := <-it.logs: - it.Event = new(BridgeFraudChallengeDefeated) + it.Event = new(BridgeEcdsaFraudRouterSet) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -2453,7 +2880,7 @@ func (it *BridgeFraudChallengeDefeatedIterator) Next() bool { // Iterator still in progress, wait for either a data or an error event select { case log := <-it.logs: - it.Event = new(BridgeFraudChallengeDefeated) + it.Event = new(BridgeEcdsaFraudRouterSet) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -2469,52 +2896,41 @@ func (it *BridgeFraudChallengeDefeatedIterator) Next() bool { } // Error returns any retrieval or parsing error occurred during filtering. -func (it *BridgeFraudChallengeDefeatedIterator) Error() error { +func (it *BridgeEcdsaFraudRouterSetIterator) Error() error { return it.fail } // Close terminates the iteration process, releasing any pending underlying // resources. -func (it *BridgeFraudChallengeDefeatedIterator) Close() error { +func (it *BridgeEcdsaFraudRouterSetIterator) Close() error { it.sub.Unsubscribe() return nil } -// BridgeFraudChallengeDefeated represents a FraudChallengeDefeated event raised by the Bridge contract. -type BridgeFraudChallengeDefeated struct { - WalletPubKeyHash [20]byte - Sighash [32]byte +// BridgeEcdsaFraudRouterSet represents a EcdsaFraudRouterSet event raised by the Bridge contract. +type BridgeEcdsaFraudRouterSet struct { + EcdsaFraudRouter common.Address Raw types.Log // Blockchain specific contextual infos } -// FilterFraudChallengeDefeated is a free log retrieval operation binding the contract event 0x6ff720470ffad78f316655e2c7fc77a76763c13de0e19ee52149916ba7e44d3b. +// FilterEcdsaFraudRouterSet is a free log retrieval operation binding the contract event 0x74b82ffdaa86ef071c7c5083b76052a32b9d67ead5e1013cba6979a28d1851c1. // -// Solidity: event FraudChallengeDefeated(bytes20 indexed walletPubKeyHash, bytes32 sighash) -func (_Bridge *BridgeFilterer) FilterFraudChallengeDefeated(opts *bind.FilterOpts, walletPubKeyHash [][20]byte) (*BridgeFraudChallengeDefeatedIterator, error) { - - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) - } +// Solidity: event EcdsaFraudRouterSet(address ecdsaFraudRouter) +func (_Bridge *BridgeFilterer) FilterEcdsaFraudRouterSet(opts *bind.FilterOpts) (*BridgeEcdsaFraudRouterSetIterator, error) { - logs, sub, err := _Bridge.contract.FilterLogs(opts, "FraudChallengeDefeated", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.FilterLogs(opts, "EcdsaFraudRouterSet") if err != nil { return nil, err } - return &BridgeFraudChallengeDefeatedIterator{contract: _Bridge.contract, event: "FraudChallengeDefeated", logs: logs, sub: sub}, nil + return &BridgeEcdsaFraudRouterSetIterator{contract: _Bridge.contract, event: "EcdsaFraudRouterSet", logs: logs, sub: sub}, nil } -// WatchFraudChallengeDefeated is a free log subscription operation binding the contract event 0x6ff720470ffad78f316655e2c7fc77a76763c13de0e19ee52149916ba7e44d3b. +// WatchEcdsaFraudRouterSet is a free log subscription operation binding the contract event 0x74b82ffdaa86ef071c7c5083b76052a32b9d67ead5e1013cba6979a28d1851c1. // -// Solidity: event FraudChallengeDefeated(bytes20 indexed walletPubKeyHash, bytes32 sighash) -func (_Bridge *BridgeFilterer) WatchFraudChallengeDefeated(opts *bind.WatchOpts, sink chan<- *BridgeFraudChallengeDefeated, walletPubKeyHash [][20]byte) (event.Subscription, error) { - - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) - } +// Solidity: event EcdsaFraudRouterSet(address ecdsaFraudRouter) +func (_Bridge *BridgeFilterer) WatchEcdsaFraudRouterSet(opts *bind.WatchOpts, sink chan<- *BridgeEcdsaFraudRouterSet) (event.Subscription, error) { - logs, sub, err := _Bridge.contract.WatchLogs(opts, "FraudChallengeDefeated", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.WatchLogs(opts, "EcdsaFraudRouterSet") if err != nil { return nil, err } @@ -2524,8 +2940,8 @@ func (_Bridge *BridgeFilterer) WatchFraudChallengeDefeated(opts *bind.WatchOpts, select { case log := <-logs: // New log arrived, parse the event and forward to the user - event := new(BridgeFraudChallengeDefeated) - if err := _Bridge.contract.UnpackLog(event, "FraudChallengeDefeated", log); err != nil { + event := new(BridgeEcdsaFraudRouterSet) + if err := _Bridge.contract.UnpackLog(event, "EcdsaFraudRouterSet", log); err != nil { return err } event.Raw = log @@ -2546,21 +2962,21 @@ func (_Bridge *BridgeFilterer) WatchFraudChallengeDefeated(opts *bind.WatchOpts, }), nil } -// ParseFraudChallengeDefeated is a log parse operation binding the contract event 0x6ff720470ffad78f316655e2c7fc77a76763c13de0e19ee52149916ba7e44d3b. +// ParseEcdsaFraudRouterSet is a log parse operation binding the contract event 0x74b82ffdaa86ef071c7c5083b76052a32b9d67ead5e1013cba6979a28d1851c1. // -// Solidity: event FraudChallengeDefeated(bytes20 indexed walletPubKeyHash, bytes32 sighash) -func (_Bridge *BridgeFilterer) ParseFraudChallengeDefeated(log types.Log) (*BridgeFraudChallengeDefeated, error) { - event := new(BridgeFraudChallengeDefeated) - if err := _Bridge.contract.UnpackLog(event, "FraudChallengeDefeated", log); err != nil { +// Solidity: event EcdsaFraudRouterSet(address ecdsaFraudRouter) +func (_Bridge *BridgeFilterer) ParseEcdsaFraudRouterSet(log types.Log) (*BridgeEcdsaFraudRouterSet, error) { + event := new(BridgeEcdsaFraudRouterSet) + if err := _Bridge.contract.UnpackLog(event, "EcdsaFraudRouterSet", log); err != nil { return nil, err } event.Raw = log return event, nil } -// BridgeFraudChallengeSubmittedIterator is returned from FilterFraudChallengeSubmitted and is used to iterate over the raw logs and unpacked data for FraudChallengeSubmitted events raised by the Bridge contract. -type BridgeFraudChallengeSubmittedIterator struct { - Event *BridgeFraudChallengeSubmitted // Event containing the contract specifics and raw log +// BridgeEcdsaRetiredIterator is returned from FilterEcdsaRetired and is used to iterate over the raw logs and unpacked data for EcdsaRetired events raised by the Bridge contract. +type BridgeEcdsaRetiredIterator struct { + Event *BridgeEcdsaRetired // Event containing the contract specifics and raw log contract *bind.BoundContract // Generic contract to use for unpacking event data event string // Event name to use for unpacking event data @@ -2574,7 +2990,7 @@ type BridgeFraudChallengeSubmittedIterator struct { // Next advances the iterator to the subsequent event, returning whether there // are any more events found. In case of a retrieval or parsing error, false is // returned and Error() can be queried for the exact failure. -func (it *BridgeFraudChallengeSubmittedIterator) Next() bool { +func (it *BridgeEcdsaRetiredIterator) Next() bool { // If the iterator failed, stop iterating if it.fail != nil { return false @@ -2583,7 +2999,7 @@ func (it *BridgeFraudChallengeSubmittedIterator) Next() bool { if it.done { select { case log := <-it.logs: - it.Event = new(BridgeFraudChallengeSubmitted) + it.Event = new(BridgeEcdsaRetired) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -2598,7 +3014,7 @@ func (it *BridgeFraudChallengeSubmittedIterator) Next() bool { // Iterator still in progress, wait for either a data or an error event select { case log := <-it.logs: - it.Event = new(BridgeFraudChallengeSubmitted) + it.Event = new(BridgeEcdsaRetired) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -2614,55 +3030,40 @@ func (it *BridgeFraudChallengeSubmittedIterator) Next() bool { } // Error returns any retrieval or parsing error occurred during filtering. -func (it *BridgeFraudChallengeSubmittedIterator) Error() error { +func (it *BridgeEcdsaRetiredIterator) Error() error { return it.fail } // Close terminates the iteration process, releasing any pending underlying // resources. -func (it *BridgeFraudChallengeSubmittedIterator) Close() error { +func (it *BridgeEcdsaRetiredIterator) Close() error { it.sub.Unsubscribe() return nil } -// BridgeFraudChallengeSubmitted represents a FraudChallengeSubmitted event raised by the Bridge contract. -type BridgeFraudChallengeSubmitted struct { - WalletPubKeyHash [20]byte - Sighash [32]byte - V uint8 - R [32]byte - S [32]byte - Raw types.Log // Blockchain specific contextual infos +// BridgeEcdsaRetired represents a EcdsaRetired event raised by the Bridge contract. +type BridgeEcdsaRetired struct { + Raw types.Log // Blockchain specific contextual infos } -// FilterFraudChallengeSubmitted is a free log retrieval operation binding the contract event 0xf4aa58d09ba5de017eac597806dfcfc2cad287816cb1eb7729a032c82680c94d. +// FilterEcdsaRetired is a free log retrieval operation binding the contract event 0xcfd6ec30c5fce5bd571f7b6c440f26edaa4ed4e92387c12806fc47ed888fd014. // -// Solidity: event FraudChallengeSubmitted(bytes20 indexed walletPubKeyHash, bytes32 sighash, uint8 v, bytes32 r, bytes32 s) -func (_Bridge *BridgeFilterer) FilterFraudChallengeSubmitted(opts *bind.FilterOpts, walletPubKeyHash [][20]byte) (*BridgeFraudChallengeSubmittedIterator, error) { - - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) - } +// Solidity: event EcdsaRetired() +func (_Bridge *BridgeFilterer) FilterEcdsaRetired(opts *bind.FilterOpts) (*BridgeEcdsaRetiredIterator, error) { - logs, sub, err := _Bridge.contract.FilterLogs(opts, "FraudChallengeSubmitted", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.FilterLogs(opts, "EcdsaRetired") if err != nil { return nil, err } - return &BridgeFraudChallengeSubmittedIterator{contract: _Bridge.contract, event: "FraudChallengeSubmitted", logs: logs, sub: sub}, nil + return &BridgeEcdsaRetiredIterator{contract: _Bridge.contract, event: "EcdsaRetired", logs: logs, sub: sub}, nil } -// WatchFraudChallengeSubmitted is a free log subscription operation binding the contract event 0xf4aa58d09ba5de017eac597806dfcfc2cad287816cb1eb7729a032c82680c94d. +// WatchEcdsaRetired is a free log subscription operation binding the contract event 0xcfd6ec30c5fce5bd571f7b6c440f26edaa4ed4e92387c12806fc47ed888fd014. // -// Solidity: event FraudChallengeSubmitted(bytes20 indexed walletPubKeyHash, bytes32 sighash, uint8 v, bytes32 r, bytes32 s) -func (_Bridge *BridgeFilterer) WatchFraudChallengeSubmitted(opts *bind.WatchOpts, sink chan<- *BridgeFraudChallengeSubmitted, walletPubKeyHash [][20]byte) (event.Subscription, error) { +// Solidity: event EcdsaRetired() +func (_Bridge *BridgeFilterer) WatchEcdsaRetired(opts *bind.WatchOpts, sink chan<- *BridgeEcdsaRetired) (event.Subscription, error) { - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) - } - - logs, sub, err := _Bridge.contract.WatchLogs(opts, "FraudChallengeSubmitted", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.WatchLogs(opts, "EcdsaRetired") if err != nil { return nil, err } @@ -2672,8 +3073,8 @@ func (_Bridge *BridgeFilterer) WatchFraudChallengeSubmitted(opts *bind.WatchOpts select { case log := <-logs: // New log arrived, parse the event and forward to the user - event := new(BridgeFraudChallengeSubmitted) - if err := _Bridge.contract.UnpackLog(event, "FraudChallengeSubmitted", log); err != nil { + event := new(BridgeEcdsaRetired) + if err := _Bridge.contract.UnpackLog(event, "EcdsaRetired", log); err != nil { return err } event.Raw = log @@ -2694,12 +3095,12 @@ func (_Bridge *BridgeFilterer) WatchFraudChallengeSubmitted(opts *bind.WatchOpts }), nil } -// ParseFraudChallengeSubmitted is a log parse operation binding the contract event 0xf4aa58d09ba5de017eac597806dfcfc2cad287816cb1eb7729a032c82680c94d. +// ParseEcdsaRetired is a log parse operation binding the contract event 0xcfd6ec30c5fce5bd571f7b6c440f26edaa4ed4e92387c12806fc47ed888fd014. // -// Solidity: event FraudChallengeSubmitted(bytes20 indexed walletPubKeyHash, bytes32 sighash, uint8 v, bytes32 r, bytes32 s) -func (_Bridge *BridgeFilterer) ParseFraudChallengeSubmitted(log types.Log) (*BridgeFraudChallengeSubmitted, error) { - event := new(BridgeFraudChallengeSubmitted) - if err := _Bridge.contract.UnpackLog(event, "FraudChallengeSubmitted", log); err != nil { +// Solidity: event EcdsaRetired() +func (_Bridge *BridgeFilterer) ParseEcdsaRetired(log types.Log) (*BridgeEcdsaRetired, error) { + event := new(BridgeEcdsaRetired) + if err := _Bridge.contract.UnpackLog(event, "EcdsaRetired", log); err != nil { return nil, err } event.Raw = log @@ -2843,6 +3244,140 @@ func (_Bridge *BridgeFilterer) ParseFraudParametersUpdated(log types.Log) (*Brid return event, nil } +// BridgeFrostWalletRegistrySetIterator is returned from FilterFrostWalletRegistrySet and is used to iterate over the raw logs and unpacked data for FrostWalletRegistrySet events raised by the Bridge contract. +type BridgeFrostWalletRegistrySetIterator struct { + Event *BridgeFrostWalletRegistrySet // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeFrostWalletRegistrySetIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeFrostWalletRegistrySet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeFrostWalletRegistrySet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeFrostWalletRegistrySetIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeFrostWalletRegistrySetIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeFrostWalletRegistrySet represents a FrostWalletRegistrySet event raised by the Bridge contract. +type BridgeFrostWalletRegistrySet struct { + FrostWalletRegistry common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterFrostWalletRegistrySet is a free log retrieval operation binding the contract event 0xdbe373e942a6a777b9b8e4970445ff3dee716310d6d5d2265c7b01947776b6df. +// +// Solidity: event FrostWalletRegistrySet(address frostWalletRegistry) +func (_Bridge *BridgeFilterer) FilterFrostWalletRegistrySet(opts *bind.FilterOpts) (*BridgeFrostWalletRegistrySetIterator, error) { + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "FrostWalletRegistrySet") + if err != nil { + return nil, err + } + return &BridgeFrostWalletRegistrySetIterator{contract: _Bridge.contract, event: "FrostWalletRegistrySet", logs: logs, sub: sub}, nil +} + +// WatchFrostWalletRegistrySet is a free log subscription operation binding the contract event 0xdbe373e942a6a777b9b8e4970445ff3dee716310d6d5d2265c7b01947776b6df. +// +// Solidity: event FrostWalletRegistrySet(address frostWalletRegistry) +func (_Bridge *BridgeFilterer) WatchFrostWalletRegistrySet(opts *bind.WatchOpts, sink chan<- *BridgeFrostWalletRegistrySet) (event.Subscription, error) { + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "FrostWalletRegistrySet") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeFrostWalletRegistrySet) + if err := _Bridge.contract.UnpackLog(event, "FrostWalletRegistrySet", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseFrostWalletRegistrySet is a log parse operation binding the contract event 0xdbe373e942a6a777b9b8e4970445ff3dee716310d6d5d2265c7b01947776b6df. +// +// Solidity: event FrostWalletRegistrySet(address frostWalletRegistry) +func (_Bridge *BridgeFilterer) ParseFrostWalletRegistrySet(log types.Log) (*BridgeFrostWalletRegistrySet, error) { + event := new(BridgeFrostWalletRegistrySet) + if err := _Bridge.contract.UnpackLog(event, "FrostWalletRegistrySet", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BridgeGovernanceTransferredIterator is returned from FilterGovernanceTransferred and is used to iterate over the raw logs and unpacked data for GovernanceTransferred events raised by the Bridge contract. type BridgeGovernanceTransferredIterator struct { Event *BridgeGovernanceTransferred // Event containing the contract specifics and raw log @@ -3112,9 +3647,9 @@ func (_Bridge *BridgeFilterer) ParseInitialized(log types.Log) (*BridgeInitializ return event, nil } -// BridgeMovedFundsSweepTimedOutIterator is returned from FilterMovedFundsSweepTimedOut and is used to iterate over the raw logs and unpacked data for MovedFundsSweepTimedOut events raised by the Bridge contract. -type BridgeMovedFundsSweepTimedOutIterator struct { - Event *BridgeMovedFundsSweepTimedOut // Event containing the contract specifics and raw log +// BridgeLegacyFraudChallengeMigratedIterator is returned from FilterLegacyFraudChallengeMigrated and is used to iterate over the raw logs and unpacked data for LegacyFraudChallengeMigrated events raised by the Bridge contract. +type BridgeLegacyFraudChallengeMigratedIterator struct { + Event *BridgeLegacyFraudChallengeMigrated // Event containing the contract specifics and raw log contract *bind.BoundContract // Generic contract to use for unpacking event data event string // Event name to use for unpacking event data @@ -3128,7 +3663,7 @@ type BridgeMovedFundsSweepTimedOutIterator struct { // Next advances the iterator to the subsequent event, returning whether there // are any more events found. In case of a retrieval or parsing error, false is // returned and Error() can be queried for the exact failure. -func (it *BridgeMovedFundsSweepTimedOutIterator) Next() bool { +func (it *BridgeLegacyFraudChallengeMigratedIterator) Next() bool { // If the iterator failed, stop iterating if it.fail != nil { return false @@ -3137,7 +3672,7 @@ func (it *BridgeMovedFundsSweepTimedOutIterator) Next() bool { if it.done { select { case log := <-it.logs: - it.Event = new(BridgeMovedFundsSweepTimedOut) + it.Event = new(BridgeLegacyFraudChallengeMigrated) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -3152,7 +3687,7 @@ func (it *BridgeMovedFundsSweepTimedOutIterator) Next() bool { // Iterator still in progress, wait for either a data or an error event select { case log := <-it.logs: - it.Event = new(BridgeMovedFundsSweepTimedOut) + it.Event = new(BridgeLegacyFraudChallengeMigrated) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -3168,50 +3703,347 @@ func (it *BridgeMovedFundsSweepTimedOutIterator) Next() bool { } // Error returns any retrieval or parsing error occurred during filtering. -func (it *BridgeMovedFundsSweepTimedOutIterator) Error() error { +func (it *BridgeLegacyFraudChallengeMigratedIterator) Error() error { return it.fail } // Close terminates the iteration process, releasing any pending underlying // resources. -func (it *BridgeMovedFundsSweepTimedOutIterator) Close() error { +func (it *BridgeLegacyFraudChallengeMigratedIterator) Close() error { it.sub.Unsubscribe() return nil } -// BridgeMovedFundsSweepTimedOut represents a MovedFundsSweepTimedOut event raised by the Bridge contract. -type BridgeMovedFundsSweepTimedOut struct { - WalletPubKeyHash [20]byte - MovingFundsTxHash [32]byte - MovingFundsTxOutputIndex uint32 - Raw types.Log // Blockchain specific contextual infos +// BridgeLegacyFraudChallengeMigrated represents a LegacyFraudChallengeMigrated event raised by the Bridge contract. +type BridgeLegacyFraudChallengeMigrated struct { + RouterKind uint8 + ChallengeKey *big.Int + Challenger common.Address + DepositAmount *big.Int + Raw types.Log // Blockchain specific contextual infos } -// FilterMovedFundsSweepTimedOut is a free log retrieval operation binding the contract event 0x4c25d874672bd8dcc921a53387892a0d5c26d5dae3d368ffe83f65cc99700612. +// FilterLegacyFraudChallengeMigrated is a free log retrieval operation binding the contract event 0xef4dd86f5d8e036d15cf4958485bdef0a43da00304fa8ad123bda135dfca8f8f. // -// Solidity: event MovedFundsSweepTimedOut(bytes20 indexed walletPubKeyHash, bytes32 movingFundsTxHash, uint32 movingFundsTxOutputIndex) -func (_Bridge *BridgeFilterer) FilterMovedFundsSweepTimedOut(opts *bind.FilterOpts, walletPubKeyHash [][20]byte) (*BridgeMovedFundsSweepTimedOutIterator, error) { +// Solidity: event LegacyFraudChallengeMigrated(uint8 indexed routerKind, uint256 indexed challengeKey, address indexed challenger, uint256 depositAmount) +func (_Bridge *BridgeFilterer) FilterLegacyFraudChallengeMigrated(opts *bind.FilterOpts, routerKind []uint8, challengeKey []*big.Int, challenger []common.Address) (*BridgeLegacyFraudChallengeMigratedIterator, error) { - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + var routerKindRule []interface{} + for _, routerKindItem := range routerKind { + routerKindRule = append(routerKindRule, routerKindItem) + } + var challengeKeyRule []interface{} + for _, challengeKeyItem := range challengeKey { + challengeKeyRule = append(challengeKeyRule, challengeKeyItem) + } + var challengerRule []interface{} + for _, challengerItem := range challenger { + challengerRule = append(challengerRule, challengerItem) } - logs, sub, err := _Bridge.contract.FilterLogs(opts, "MovedFundsSweepTimedOut", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.FilterLogs(opts, "LegacyFraudChallengeMigrated", routerKindRule, challengeKeyRule, challengerRule) if err != nil { return nil, err } - return &BridgeMovedFundsSweepTimedOutIterator{contract: _Bridge.contract, event: "MovedFundsSweepTimedOut", logs: logs, sub: sub}, nil + return &BridgeLegacyFraudChallengeMigratedIterator{contract: _Bridge.contract, event: "LegacyFraudChallengeMigrated", logs: logs, sub: sub}, nil } -// WatchMovedFundsSweepTimedOut is a free log subscription operation binding the contract event 0x4c25d874672bd8dcc921a53387892a0d5c26d5dae3d368ffe83f65cc99700612. +// WatchLegacyFraudChallengeMigrated is a free log subscription operation binding the contract event 0xef4dd86f5d8e036d15cf4958485bdef0a43da00304fa8ad123bda135dfca8f8f. // -// Solidity: event MovedFundsSweepTimedOut(bytes20 indexed walletPubKeyHash, bytes32 movingFundsTxHash, uint32 movingFundsTxOutputIndex) -func (_Bridge *BridgeFilterer) WatchMovedFundsSweepTimedOut(opts *bind.WatchOpts, sink chan<- *BridgeMovedFundsSweepTimedOut, walletPubKeyHash [][20]byte) (event.Subscription, error) { +// Solidity: event LegacyFraudChallengeMigrated(uint8 indexed routerKind, uint256 indexed challengeKey, address indexed challenger, uint256 depositAmount) +func (_Bridge *BridgeFilterer) WatchLegacyFraudChallengeMigrated(opts *bind.WatchOpts, sink chan<- *BridgeLegacyFraudChallengeMigrated, routerKind []uint8, challengeKey []*big.Int, challenger []common.Address) (event.Subscription, error) { - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + var routerKindRule []interface{} + for _, routerKindItem := range routerKind { + routerKindRule = append(routerKindRule, routerKindItem) + } + var challengeKeyRule []interface{} + for _, challengeKeyItem := range challengeKey { + challengeKeyRule = append(challengeKeyRule, challengeKeyItem) + } + var challengerRule []interface{} + for _, challengerItem := range challenger { + challengerRule = append(challengerRule, challengerItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "LegacyFraudChallengeMigrated", routerKindRule, challengeKeyRule, challengerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeLegacyFraudChallengeMigrated) + if err := _Bridge.contract.UnpackLog(event, "LegacyFraudChallengeMigrated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseLegacyFraudChallengeMigrated is a log parse operation binding the contract event 0xef4dd86f5d8e036d15cf4958485bdef0a43da00304fa8ad123bda135dfca8f8f. +// +// Solidity: event LegacyFraudChallengeMigrated(uint8 indexed routerKind, uint256 indexed challengeKey, address indexed challenger, uint256 depositAmount) +func (_Bridge *BridgeFilterer) ParseLegacyFraudChallengeMigrated(log types.Log) (*BridgeLegacyFraudChallengeMigrated, error) { + event := new(BridgeLegacyFraudChallengeMigrated) + if err := _Bridge.contract.UnpackLog(event, "LegacyFraudChallengeMigrated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// BridgeLifecycleRouterSetIterator is returned from FilterLifecycleRouterSet and is used to iterate over the raw logs and unpacked data for LifecycleRouterSet events raised by the Bridge contract. +type BridgeLifecycleRouterSetIterator struct { + Event *BridgeLifecycleRouterSet // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeLifecycleRouterSetIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeLifecycleRouterSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeLifecycleRouterSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeLifecycleRouterSetIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeLifecycleRouterSetIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeLifecycleRouterSet represents a LifecycleRouterSet event raised by the Bridge contract. +type BridgeLifecycleRouterSet struct { + LifecycleRouter common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterLifecycleRouterSet is a free log retrieval operation binding the contract event 0xd34c360c4ba3b0ef69ec75dd2fd413d2432504b21290e9dfdd9d0bffab5376d7. +// +// Solidity: event LifecycleRouterSet(address lifecycleRouter) +func (_Bridge *BridgeFilterer) FilterLifecycleRouterSet(opts *bind.FilterOpts) (*BridgeLifecycleRouterSetIterator, error) { + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "LifecycleRouterSet") + if err != nil { + return nil, err + } + return &BridgeLifecycleRouterSetIterator{contract: _Bridge.contract, event: "LifecycleRouterSet", logs: logs, sub: sub}, nil +} + +// WatchLifecycleRouterSet is a free log subscription operation binding the contract event 0xd34c360c4ba3b0ef69ec75dd2fd413d2432504b21290e9dfdd9d0bffab5376d7. +// +// Solidity: event LifecycleRouterSet(address lifecycleRouter) +func (_Bridge *BridgeFilterer) WatchLifecycleRouterSet(opts *bind.WatchOpts, sink chan<- *BridgeLifecycleRouterSet) (event.Subscription, error) { + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "LifecycleRouterSet") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeLifecycleRouterSet) + if err := _Bridge.contract.UnpackLog(event, "LifecycleRouterSet", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseLifecycleRouterSet is a log parse operation binding the contract event 0xd34c360c4ba3b0ef69ec75dd2fd413d2432504b21290e9dfdd9d0bffab5376d7. +// +// Solidity: event LifecycleRouterSet(address lifecycleRouter) +func (_Bridge *BridgeFilterer) ParseLifecycleRouterSet(log types.Log) (*BridgeLifecycleRouterSet, error) { + event := new(BridgeLifecycleRouterSet) + if err := _Bridge.contract.UnpackLog(event, "LifecycleRouterSet", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// BridgeMovedFundsSweepTimedOutIterator is returned from FilterMovedFundsSweepTimedOut and is used to iterate over the raw logs and unpacked data for MovedFundsSweepTimedOut events raised by the Bridge contract. +type BridgeMovedFundsSweepTimedOutIterator struct { + Event *BridgeMovedFundsSweepTimedOut // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeMovedFundsSweepTimedOutIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeMovedFundsSweepTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeMovedFundsSweepTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeMovedFundsSweepTimedOutIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeMovedFundsSweepTimedOutIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeMovedFundsSweepTimedOut represents a MovedFundsSweepTimedOut event raised by the Bridge contract. +type BridgeMovedFundsSweepTimedOut struct { + WalletPubKeyHash [20]byte + MovingFundsTxHash [32]byte + MovingFundsTxOutputIndex uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterMovedFundsSweepTimedOut is a free log retrieval operation binding the contract event 0x4c25d874672bd8dcc921a53387892a0d5c26d5dae3d368ffe83f65cc99700612. +// +// Solidity: event MovedFundsSweepTimedOut(bytes20 indexed walletPubKeyHash, bytes32 movingFundsTxHash, uint32 movingFundsTxOutputIndex) +func (_Bridge *BridgeFilterer) FilterMovedFundsSweepTimedOut(opts *bind.FilterOpts, walletPubKeyHash [][20]byte) (*BridgeMovedFundsSweepTimedOutIterator, error) { + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "MovedFundsSweepTimedOut", walletPubKeyHashRule) + if err != nil { + return nil, err + } + return &BridgeMovedFundsSweepTimedOutIterator{contract: _Bridge.contract, event: "MovedFundsSweepTimedOut", logs: logs, sub: sub}, nil +} + +// WatchMovedFundsSweepTimedOut is a free log subscription operation binding the contract event 0x4c25d874672bd8dcc921a53387892a0d5c26d5dae3d368ffe83f65cc99700612. +// +// Solidity: event MovedFundsSweepTimedOut(bytes20 indexed walletPubKeyHash, bytes32 movingFundsTxHash, uint32 movingFundsTxOutputIndex) +func (_Bridge *BridgeFilterer) WatchMovedFundsSweepTimedOut(opts *bind.WatchOpts, sink chan<- *BridgeMovedFundsSweepTimedOut, walletPubKeyHash [][20]byte) (event.Subscription, error) { + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) } logs, sub, err := _Bridge.contract.WatchLogs(opts, "MovedFundsSweepTimedOut", walletPubKeyHashRule) @@ -4082,7 +4914,761 @@ func (_Bridge *BridgeFilterer) WatchMovingFundsTimedOut(opts *bind.WatchOpts, si walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) } - logs, sub, err := _Bridge.contract.WatchLogs(opts, "MovingFundsTimedOut", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.WatchLogs(opts, "MovingFundsTimedOut", walletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeMovingFundsTimedOut) + if err := _Bridge.contract.UnpackLog(event, "MovingFundsTimedOut", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseMovingFundsTimedOut is a log parse operation binding the contract event 0x5862a5a7095622ec6e3a04e5bb6547f1e4034af0d7d4d7e9787678072fc66fb2. +// +// Solidity: event MovingFundsTimedOut(bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) ParseMovingFundsTimedOut(log types.Log) (*BridgeMovingFundsTimedOut, error) { + event := new(BridgeMovingFundsTimedOut) + if err := _Bridge.contract.UnpackLog(event, "MovingFundsTimedOut", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// BridgeMovingFundsTimeoutResetIterator is returned from FilterMovingFundsTimeoutReset and is used to iterate over the raw logs and unpacked data for MovingFundsTimeoutReset events raised by the Bridge contract. +type BridgeMovingFundsTimeoutResetIterator struct { + Event *BridgeMovingFundsTimeoutReset // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeMovingFundsTimeoutResetIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeMovingFundsTimeoutReset) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeMovingFundsTimeoutReset) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeMovingFundsTimeoutResetIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeMovingFundsTimeoutResetIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeMovingFundsTimeoutReset represents a MovingFundsTimeoutReset event raised by the Bridge contract. +type BridgeMovingFundsTimeoutReset struct { + WalletPubKeyHash [20]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterMovingFundsTimeoutReset is a free log retrieval operation binding the contract event 0xa59c6e2153c28ecd6a3507cfa44c3ab392779ff484a1aee02e40345c63a59bc0. +// +// Solidity: event MovingFundsTimeoutReset(bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) FilterMovingFundsTimeoutReset(opts *bind.FilterOpts, walletPubKeyHash [][20]byte) (*BridgeMovingFundsTimeoutResetIterator, error) { + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "MovingFundsTimeoutReset", walletPubKeyHashRule) + if err != nil { + return nil, err + } + return &BridgeMovingFundsTimeoutResetIterator{contract: _Bridge.contract, event: "MovingFundsTimeoutReset", logs: logs, sub: sub}, nil +} + +// WatchMovingFundsTimeoutReset is a free log subscription operation binding the contract event 0xa59c6e2153c28ecd6a3507cfa44c3ab392779ff484a1aee02e40345c63a59bc0. +// +// Solidity: event MovingFundsTimeoutReset(bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) WatchMovingFundsTimeoutReset(opts *bind.WatchOpts, sink chan<- *BridgeMovingFundsTimeoutReset, walletPubKeyHash [][20]byte) (event.Subscription, error) { + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "MovingFundsTimeoutReset", walletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeMovingFundsTimeoutReset) + if err := _Bridge.contract.UnpackLog(event, "MovingFundsTimeoutReset", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseMovingFundsTimeoutReset is a log parse operation binding the contract event 0xa59c6e2153c28ecd6a3507cfa44c3ab392779ff484a1aee02e40345c63a59bc0. +// +// Solidity: event MovingFundsTimeoutReset(bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) ParseMovingFundsTimeoutReset(log types.Log) (*BridgeMovingFundsTimeoutReset, error) { + event := new(BridgeMovingFundsTimeoutReset) + if err := _Bridge.contract.UnpackLog(event, "MovingFundsTimeoutReset", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// BridgeNewFrostWalletRegisteredIterator is returned from FilterNewFrostWalletRegistered and is used to iterate over the raw logs and unpacked data for NewFrostWalletRegistered events raised by the Bridge contract. +type BridgeNewFrostWalletRegisteredIterator struct { + Event *BridgeNewFrostWalletRegistered // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeNewFrostWalletRegisteredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeNewFrostWalletRegistered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeNewFrostWalletRegistered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeNewFrostWalletRegisteredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeNewFrostWalletRegisteredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeNewFrostWalletRegistered represents a NewFrostWalletRegistered event raised by the Bridge contract. +type BridgeNewFrostWalletRegistered struct { + WalletID [32]byte + WalletPubKeyHash [20]byte + XOnlyOutputKey [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterNewFrostWalletRegistered is a free log retrieval operation binding the contract event 0xd9aa9c3636339f9edab116054e0fff7f31ff75da8fb201345c31980bb7644334. +// +// Solidity: event NewFrostWalletRegistered(bytes32 indexed walletID, bytes20 indexed walletPubKeyHash, bytes32 indexed xOnlyOutputKey) +func (_Bridge *BridgeFilterer) FilterNewFrostWalletRegistered(opts *bind.FilterOpts, walletID [][32]byte, walletPubKeyHash [][20]byte, xOnlyOutputKey [][32]byte) (*BridgeNewFrostWalletRegisteredIterator, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var xOnlyOutputKeyRule []interface{} + for _, xOnlyOutputKeyItem := range xOnlyOutputKey { + xOnlyOutputKeyRule = append(xOnlyOutputKeyRule, xOnlyOutputKeyItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "NewFrostWalletRegistered", walletIDRule, walletPubKeyHashRule, xOnlyOutputKeyRule) + if err != nil { + return nil, err + } + return &BridgeNewFrostWalletRegisteredIterator{contract: _Bridge.contract, event: "NewFrostWalletRegistered", logs: logs, sub: sub}, nil +} + +// WatchNewFrostWalletRegistered is a free log subscription operation binding the contract event 0xd9aa9c3636339f9edab116054e0fff7f31ff75da8fb201345c31980bb7644334. +// +// Solidity: event NewFrostWalletRegistered(bytes32 indexed walletID, bytes20 indexed walletPubKeyHash, bytes32 indexed xOnlyOutputKey) +func (_Bridge *BridgeFilterer) WatchNewFrostWalletRegistered(opts *bind.WatchOpts, sink chan<- *BridgeNewFrostWalletRegistered, walletID [][32]byte, walletPubKeyHash [][20]byte, xOnlyOutputKey [][32]byte) (event.Subscription, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var xOnlyOutputKeyRule []interface{} + for _, xOnlyOutputKeyItem := range xOnlyOutputKey { + xOnlyOutputKeyRule = append(xOnlyOutputKeyRule, xOnlyOutputKeyItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "NewFrostWalletRegistered", walletIDRule, walletPubKeyHashRule, xOnlyOutputKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeNewFrostWalletRegistered) + if err := _Bridge.contract.UnpackLog(event, "NewFrostWalletRegistered", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseNewFrostWalletRegistered is a log parse operation binding the contract event 0xd9aa9c3636339f9edab116054e0fff7f31ff75da8fb201345c31980bb7644334. +// +// Solidity: event NewFrostWalletRegistered(bytes32 indexed walletID, bytes20 indexed walletPubKeyHash, bytes32 indexed xOnlyOutputKey) +func (_Bridge *BridgeFilterer) ParseNewFrostWalletRegistered(log types.Log) (*BridgeNewFrostWalletRegistered, error) { + event := new(BridgeNewFrostWalletRegistered) + if err := _Bridge.contract.UnpackLog(event, "NewFrostWalletRegistered", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// BridgeNewWalletRegisteredIterator is returned from FilterNewWalletRegistered and is used to iterate over the raw logs and unpacked data for NewWalletRegistered events raised by the Bridge contract. +type BridgeNewWalletRegisteredIterator struct { + Event *BridgeNewWalletRegistered // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeNewWalletRegisteredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeNewWalletRegistered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeNewWalletRegistered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeNewWalletRegisteredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeNewWalletRegisteredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeNewWalletRegistered represents a NewWalletRegistered event raised by the Bridge contract. +type BridgeNewWalletRegistered struct { + EcdsaWalletID [32]byte + WalletPubKeyHash [20]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterNewWalletRegistered is a free log retrieval operation binding the contract event 0x2dbb47dce81d6b11cca1f1e3b10143d6f7e1e7e92d2dd9aacbb1f875379d308e. +// +// Solidity: event NewWalletRegistered(bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) FilterNewWalletRegistered(opts *bind.FilterOpts, ecdsaWalletID [][32]byte, walletPubKeyHash [][20]byte) (*BridgeNewWalletRegisteredIterator, error) { + + var ecdsaWalletIDRule []interface{} + for _, ecdsaWalletIDItem := range ecdsaWalletID { + ecdsaWalletIDRule = append(ecdsaWalletIDRule, ecdsaWalletIDItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "NewWalletRegistered", ecdsaWalletIDRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return &BridgeNewWalletRegisteredIterator{contract: _Bridge.contract, event: "NewWalletRegistered", logs: logs, sub: sub}, nil +} + +// WatchNewWalletRegistered is a free log subscription operation binding the contract event 0x2dbb47dce81d6b11cca1f1e3b10143d6f7e1e7e92d2dd9aacbb1f875379d308e. +// +// Solidity: event NewWalletRegistered(bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) WatchNewWalletRegistered(opts *bind.WatchOpts, sink chan<- *BridgeNewWalletRegistered, ecdsaWalletID [][32]byte, walletPubKeyHash [][20]byte) (event.Subscription, error) { + + var ecdsaWalletIDRule []interface{} + for _, ecdsaWalletIDItem := range ecdsaWalletID { + ecdsaWalletIDRule = append(ecdsaWalletIDRule, ecdsaWalletIDItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "NewWalletRegistered", ecdsaWalletIDRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeNewWalletRegistered) + if err := _Bridge.contract.UnpackLog(event, "NewWalletRegistered", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseNewWalletRegistered is a log parse operation binding the contract event 0x2dbb47dce81d6b11cca1f1e3b10143d6f7e1e7e92d2dd9aacbb1f875379d308e. +// +// Solidity: event NewWalletRegistered(bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) ParseNewWalletRegistered(log types.Log) (*BridgeNewWalletRegistered, error) { + event := new(BridgeNewWalletRegistered) + if err := _Bridge.contract.UnpackLog(event, "NewWalletRegistered", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// BridgeNewWalletRegisteredV2Iterator is returned from FilterNewWalletRegisteredV2 and is used to iterate over the raw logs and unpacked data for NewWalletRegisteredV2 events raised by the Bridge contract. +type BridgeNewWalletRegisteredV2Iterator struct { + Event *BridgeNewWalletRegisteredV2 // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeNewWalletRegisteredV2Iterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeNewWalletRegisteredV2) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeNewWalletRegisteredV2) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeNewWalletRegisteredV2Iterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeNewWalletRegisteredV2Iterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeNewWalletRegisteredV2 represents a NewWalletRegisteredV2 event raised by the Bridge contract. +type BridgeNewWalletRegisteredV2 struct { + WalletID [32]byte + EcdsaWalletID [32]byte + WalletPubKeyHash [20]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterNewWalletRegisteredV2 is a free log retrieval operation binding the contract event 0x6a501a1d441e1c8b5490e52589d0d27d35504cf1063a8c848fef40f326710d4b. +// +// Solidity: event NewWalletRegisteredV2(bytes32 indexed walletID, bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) FilterNewWalletRegisteredV2(opts *bind.FilterOpts, walletID [][32]byte, ecdsaWalletID [][32]byte, walletPubKeyHash [][20]byte) (*BridgeNewWalletRegisteredV2Iterator, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + var ecdsaWalletIDRule []interface{} + for _, ecdsaWalletIDItem := range ecdsaWalletID { + ecdsaWalletIDRule = append(ecdsaWalletIDRule, ecdsaWalletIDItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "NewWalletRegisteredV2", walletIDRule, ecdsaWalletIDRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return &BridgeNewWalletRegisteredV2Iterator{contract: _Bridge.contract, event: "NewWalletRegisteredV2", logs: logs, sub: sub}, nil +} + +// WatchNewWalletRegisteredV2 is a free log subscription operation binding the contract event 0x6a501a1d441e1c8b5490e52589d0d27d35504cf1063a8c848fef40f326710d4b. +// +// Solidity: event NewWalletRegisteredV2(bytes32 indexed walletID, bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) WatchNewWalletRegisteredV2(opts *bind.WatchOpts, sink chan<- *BridgeNewWalletRegisteredV2, walletID [][32]byte, ecdsaWalletID [][32]byte, walletPubKeyHash [][20]byte) (event.Subscription, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + var ecdsaWalletIDRule []interface{} + for _, ecdsaWalletIDItem := range ecdsaWalletID { + ecdsaWalletIDRule = append(ecdsaWalletIDRule, ecdsaWalletIDItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "NewWalletRegisteredV2", walletIDRule, ecdsaWalletIDRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeNewWalletRegisteredV2) + if err := _Bridge.contract.UnpackLog(event, "NewWalletRegisteredV2", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseNewWalletRegisteredV2 is a log parse operation binding the contract event 0x6a501a1d441e1c8b5490e52589d0d27d35504cf1063a8c848fef40f326710d4b. +// +// Solidity: event NewWalletRegisteredV2(bytes32 indexed walletID, bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) ParseNewWalletRegisteredV2(log types.Log) (*BridgeNewWalletRegisteredV2, error) { + event := new(BridgeNewWalletRegisteredV2) + if err := _Bridge.contract.UnpackLog(event, "NewWalletRegisteredV2", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// BridgeNewWalletRequestedIterator is returned from FilterNewWalletRequested and is used to iterate over the raw logs and unpacked data for NewWalletRequested events raised by the Bridge contract. +type BridgeNewWalletRequestedIterator struct { + Event *BridgeNewWalletRequested // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeNewWalletRequestedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeNewWalletRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeNewWalletRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeNewWalletRequestedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeNewWalletRequestedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeNewWalletRequested represents a NewWalletRequested event raised by the Bridge contract. +type BridgeNewWalletRequested struct { + Raw types.Log // Blockchain specific contextual infos +} + +// FilterNewWalletRequested is a free log retrieval operation binding the contract event 0x31fecb80caf1e1128496dd5a6f1083ba29fd5fe64c3fe04e2d1b6f9cfc27d5a3. +// +// Solidity: event NewWalletRequested() +func (_Bridge *BridgeFilterer) FilterNewWalletRequested(opts *bind.FilterOpts) (*BridgeNewWalletRequestedIterator, error) { + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "NewWalletRequested") + if err != nil { + return nil, err + } + return &BridgeNewWalletRequestedIterator{contract: _Bridge.contract, event: "NewWalletRequested", logs: logs, sub: sub}, nil +} + +// WatchNewWalletRequested is a free log subscription operation binding the contract event 0x31fecb80caf1e1128496dd5a6f1083ba29fd5fe64c3fe04e2d1b6f9cfc27d5a3. +// +// Solidity: event NewWalletRequested() +func (_Bridge *BridgeFilterer) WatchNewWalletRequested(opts *bind.WatchOpts, sink chan<- *BridgeNewWalletRequested) (event.Subscription, error) { + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "NewWalletRequested") if err != nil { return nil, err } @@ -4092,8 +5678,8 @@ func (_Bridge *BridgeFilterer) WatchMovingFundsTimedOut(opts *bind.WatchOpts, si select { case log := <-logs: // New log arrived, parse the event and forward to the user - event := new(BridgeMovingFundsTimedOut) - if err := _Bridge.contract.UnpackLog(event, "MovingFundsTimedOut", log); err != nil { + event := new(BridgeNewWalletRequested) + if err := _Bridge.contract.UnpackLog(event, "NewWalletRequested", log); err != nil { return err } event.Raw = log @@ -4114,21 +5700,21 @@ func (_Bridge *BridgeFilterer) WatchMovingFundsTimedOut(opts *bind.WatchOpts, si }), nil } -// ParseMovingFundsTimedOut is a log parse operation binding the contract event 0x5862a5a7095622ec6e3a04e5bb6547f1e4034af0d7d4d7e9787678072fc66fb2. +// ParseNewWalletRequested is a log parse operation binding the contract event 0x31fecb80caf1e1128496dd5a6f1083ba29fd5fe64c3fe04e2d1b6f9cfc27d5a3. // -// Solidity: event MovingFundsTimedOut(bytes20 indexed walletPubKeyHash) -func (_Bridge *BridgeFilterer) ParseMovingFundsTimedOut(log types.Log) (*BridgeMovingFundsTimedOut, error) { - event := new(BridgeMovingFundsTimedOut) - if err := _Bridge.contract.UnpackLog(event, "MovingFundsTimedOut", log); err != nil { +// Solidity: event NewWalletRequested() +func (_Bridge *BridgeFilterer) ParseNewWalletRequested(log types.Log) (*BridgeNewWalletRequested, error) { + event := new(BridgeNewWalletRequested) + if err := _Bridge.contract.UnpackLog(event, "NewWalletRequested", log); err != nil { return nil, err } event.Raw = log return event, nil } -// BridgeMovingFundsTimeoutResetIterator is returned from FilterMovingFundsTimeoutReset and is used to iterate over the raw logs and unpacked data for MovingFundsTimeoutReset events raised by the Bridge contract. -type BridgeMovingFundsTimeoutResetIterator struct { - Event *BridgeMovingFundsTimeoutReset // Event containing the contract specifics and raw log +// BridgeNewWalletSchemeSetIterator is returned from FilterNewWalletSchemeSet and is used to iterate over the raw logs and unpacked data for NewWalletSchemeSet events raised by the Bridge contract. +type BridgeNewWalletSchemeSetIterator struct { + Event *BridgeNewWalletSchemeSet // Event containing the contract specifics and raw log contract *bind.BoundContract // Generic contract to use for unpacking event data event string // Event name to use for unpacking event data @@ -4142,7 +5728,7 @@ type BridgeMovingFundsTimeoutResetIterator struct { // Next advances the iterator to the subsequent event, returning whether there // are any more events found. In case of a retrieval or parsing error, false is // returned and Error() can be queried for the exact failure. -func (it *BridgeMovingFundsTimeoutResetIterator) Next() bool { +func (it *BridgeNewWalletSchemeSetIterator) Next() bool { // If the iterator failed, stop iterating if it.fail != nil { return false @@ -4151,7 +5737,7 @@ func (it *BridgeMovingFundsTimeoutResetIterator) Next() bool { if it.done { select { case log := <-it.logs: - it.Event = new(BridgeMovingFundsTimeoutReset) + it.Event = new(BridgeNewWalletSchemeSet) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -4166,7 +5752,7 @@ func (it *BridgeMovingFundsTimeoutResetIterator) Next() bool { // Iterator still in progress, wait for either a data or an error event select { case log := <-it.logs: - it.Event = new(BridgeMovingFundsTimeoutReset) + it.Event = new(BridgeNewWalletSchemeSet) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -4182,51 +5768,51 @@ func (it *BridgeMovingFundsTimeoutResetIterator) Next() bool { } // Error returns any retrieval or parsing error occurred during filtering. -func (it *BridgeMovingFundsTimeoutResetIterator) Error() error { +func (it *BridgeNewWalletSchemeSetIterator) Error() error { return it.fail } // Close terminates the iteration process, releasing any pending underlying // resources. -func (it *BridgeMovingFundsTimeoutResetIterator) Close() error { +func (it *BridgeNewWalletSchemeSetIterator) Close() error { it.sub.Unsubscribe() return nil } -// BridgeMovingFundsTimeoutReset represents a MovingFundsTimeoutReset event raised by the Bridge contract. -type BridgeMovingFundsTimeoutReset struct { - WalletPubKeyHash [20]byte - Raw types.Log // Blockchain specific contextual infos +// BridgeNewWalletSchemeSet represents a NewWalletSchemeSet event raised by the Bridge contract. +type BridgeNewWalletSchemeSet struct { + Scheme uint8 + Raw types.Log // Blockchain specific contextual infos } -// FilterMovingFundsTimeoutReset is a free log retrieval operation binding the contract event 0xa59c6e2153c28ecd6a3507cfa44c3ab392779ff484a1aee02e40345c63a59bc0. +// FilterNewWalletSchemeSet is a free log retrieval operation binding the contract event 0xf02f991b885946929457e15df17c468398baff309f97deb150209e448b9157ca. // -// Solidity: event MovingFundsTimeoutReset(bytes20 indexed walletPubKeyHash) -func (_Bridge *BridgeFilterer) FilterMovingFundsTimeoutReset(opts *bind.FilterOpts, walletPubKeyHash [][20]byte) (*BridgeMovingFundsTimeoutResetIterator, error) { +// Solidity: event NewWalletSchemeSet(uint8 indexed scheme) +func (_Bridge *BridgeFilterer) FilterNewWalletSchemeSet(opts *bind.FilterOpts, scheme []uint8) (*BridgeNewWalletSchemeSetIterator, error) { - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + var schemeRule []interface{} + for _, schemeItem := range scheme { + schemeRule = append(schemeRule, schemeItem) } - logs, sub, err := _Bridge.contract.FilterLogs(opts, "MovingFundsTimeoutReset", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.FilterLogs(opts, "NewWalletSchemeSet", schemeRule) if err != nil { return nil, err } - return &BridgeMovingFundsTimeoutResetIterator{contract: _Bridge.contract, event: "MovingFundsTimeoutReset", logs: logs, sub: sub}, nil + return &BridgeNewWalletSchemeSetIterator{contract: _Bridge.contract, event: "NewWalletSchemeSet", logs: logs, sub: sub}, nil } -// WatchMovingFundsTimeoutReset is a free log subscription operation binding the contract event 0xa59c6e2153c28ecd6a3507cfa44c3ab392779ff484a1aee02e40345c63a59bc0. +// WatchNewWalletSchemeSet is a free log subscription operation binding the contract event 0xf02f991b885946929457e15df17c468398baff309f97deb150209e448b9157ca. // -// Solidity: event MovingFundsTimeoutReset(bytes20 indexed walletPubKeyHash) -func (_Bridge *BridgeFilterer) WatchMovingFundsTimeoutReset(opts *bind.WatchOpts, sink chan<- *BridgeMovingFundsTimeoutReset, walletPubKeyHash [][20]byte) (event.Subscription, error) { +// Solidity: event NewWalletSchemeSet(uint8 indexed scheme) +func (_Bridge *BridgeFilterer) WatchNewWalletSchemeSet(opts *bind.WatchOpts, sink chan<- *BridgeNewWalletSchemeSet, scheme []uint8) (event.Subscription, error) { - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + var schemeRule []interface{} + for _, schemeItem := range scheme { + schemeRule = append(schemeRule, schemeItem) } - logs, sub, err := _Bridge.contract.WatchLogs(opts, "MovingFundsTimeoutReset", walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.WatchLogs(opts, "NewWalletSchemeSet", schemeRule) if err != nil { return nil, err } @@ -4236,8 +5822,8 @@ func (_Bridge *BridgeFilterer) WatchMovingFundsTimeoutReset(opts *bind.WatchOpts select { case log := <-logs: // New log arrived, parse the event and forward to the user - event := new(BridgeMovingFundsTimeoutReset) - if err := _Bridge.contract.UnpackLog(event, "MovingFundsTimeoutReset", log); err != nil { + event := new(BridgeNewWalletSchemeSet) + if err := _Bridge.contract.UnpackLog(event, "NewWalletSchemeSet", log); err != nil { return err } event.Raw = log @@ -4258,21 +5844,21 @@ func (_Bridge *BridgeFilterer) WatchMovingFundsTimeoutReset(opts *bind.WatchOpts }), nil } -// ParseMovingFundsTimeoutReset is a log parse operation binding the contract event 0xa59c6e2153c28ecd6a3507cfa44c3ab392779ff484a1aee02e40345c63a59bc0. +// ParseNewWalletSchemeSet is a log parse operation binding the contract event 0xf02f991b885946929457e15df17c468398baff309f97deb150209e448b9157ca. // -// Solidity: event MovingFundsTimeoutReset(bytes20 indexed walletPubKeyHash) -func (_Bridge *BridgeFilterer) ParseMovingFundsTimeoutReset(log types.Log) (*BridgeMovingFundsTimeoutReset, error) { - event := new(BridgeMovingFundsTimeoutReset) - if err := _Bridge.contract.UnpackLog(event, "MovingFundsTimeoutReset", log); err != nil { +// Solidity: event NewWalletSchemeSet(uint8 indexed scheme) +func (_Bridge *BridgeFilterer) ParseNewWalletSchemeSet(log types.Log) (*BridgeNewWalletSchemeSet, error) { + event := new(BridgeNewWalletSchemeSet) + if err := _Bridge.contract.UnpackLog(event, "NewWalletSchemeSet", log); err != nil { return nil, err } event.Raw = log return event, nil } -// BridgeNewWalletRegisteredIterator is returned from FilterNewWalletRegistered and is used to iterate over the raw logs and unpacked data for NewWalletRegistered events raised by the Bridge contract. -type BridgeNewWalletRegisteredIterator struct { - Event *BridgeNewWalletRegistered // Event containing the contract specifics and raw log +// BridgeP2TRFraudRouterSetIterator is returned from FilterP2TRFraudRouterSet and is used to iterate over the raw logs and unpacked data for P2TRFraudRouterSet events raised by the Bridge contract. +type BridgeP2TRFraudRouterSetIterator struct { + Event *BridgeP2TRFraudRouterSet // Event containing the contract specifics and raw log contract *bind.BoundContract // Generic contract to use for unpacking event data event string // Event name to use for unpacking event data @@ -4286,7 +5872,7 @@ type BridgeNewWalletRegisteredIterator struct { // Next advances the iterator to the subsequent event, returning whether there // are any more events found. In case of a retrieval or parsing error, false is // returned and Error() can be queried for the exact failure. -func (it *BridgeNewWalletRegisteredIterator) Next() bool { +func (it *BridgeP2TRFraudRouterSetIterator) Next() bool { // If the iterator failed, stop iterating if it.fail != nil { return false @@ -4295,7 +5881,7 @@ func (it *BridgeNewWalletRegisteredIterator) Next() bool { if it.done { select { case log := <-it.logs: - it.Event = new(BridgeNewWalletRegistered) + it.Event = new(BridgeP2TRFraudRouterSet) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -4310,7 +5896,7 @@ func (it *BridgeNewWalletRegisteredIterator) Next() bool { // Iterator still in progress, wait for either a data or an error event select { case log := <-it.logs: - it.Event = new(BridgeNewWalletRegistered) + it.Event = new(BridgeP2TRFraudRouterSet) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -4326,60 +5912,41 @@ func (it *BridgeNewWalletRegisteredIterator) Next() bool { } // Error returns any retrieval or parsing error occurred during filtering. -func (it *BridgeNewWalletRegisteredIterator) Error() error { +func (it *BridgeP2TRFraudRouterSetIterator) Error() error { return it.fail } // Close terminates the iteration process, releasing any pending underlying // resources. -func (it *BridgeNewWalletRegisteredIterator) Close() error { +func (it *BridgeP2TRFraudRouterSetIterator) Close() error { it.sub.Unsubscribe() return nil } -// BridgeNewWalletRegistered represents a NewWalletRegistered event raised by the Bridge contract. -type BridgeNewWalletRegistered struct { - EcdsaWalletID [32]byte - WalletPubKeyHash [20]byte - Raw types.Log // Blockchain specific contextual infos +// BridgeP2TRFraudRouterSet represents a P2TRFraudRouterSet event raised by the Bridge contract. +type BridgeP2TRFraudRouterSet struct { + P2trFraudRouter common.Address + Raw types.Log // Blockchain specific contextual infos } -// FilterNewWalletRegistered is a free log retrieval operation binding the contract event 0x2dbb47dce81d6b11cca1f1e3b10143d6f7e1e7e92d2dd9aacbb1f875379d308e. +// FilterP2TRFraudRouterSet is a free log retrieval operation binding the contract event 0x083fc37b87ed978d63df891a75b6e9ab20e73e33ae9fcab66416b8d014ceee54. // -// Solidity: event NewWalletRegistered(bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) -func (_Bridge *BridgeFilterer) FilterNewWalletRegistered(opts *bind.FilterOpts, ecdsaWalletID [][32]byte, walletPubKeyHash [][20]byte) (*BridgeNewWalletRegisteredIterator, error) { - - var ecdsaWalletIDRule []interface{} - for _, ecdsaWalletIDItem := range ecdsaWalletID { - ecdsaWalletIDRule = append(ecdsaWalletIDRule, ecdsaWalletIDItem) - } - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) - } +// Solidity: event P2TRFraudRouterSet(address p2trFraudRouter) +func (_Bridge *BridgeFilterer) FilterP2TRFraudRouterSet(opts *bind.FilterOpts) (*BridgeP2TRFraudRouterSetIterator, error) { - logs, sub, err := _Bridge.contract.FilterLogs(opts, "NewWalletRegistered", ecdsaWalletIDRule, walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.FilterLogs(opts, "P2TRFraudRouterSet") if err != nil { return nil, err } - return &BridgeNewWalletRegisteredIterator{contract: _Bridge.contract, event: "NewWalletRegistered", logs: logs, sub: sub}, nil + return &BridgeP2TRFraudRouterSetIterator{contract: _Bridge.contract, event: "P2TRFraudRouterSet", logs: logs, sub: sub}, nil } -// WatchNewWalletRegistered is a free log subscription operation binding the contract event 0x2dbb47dce81d6b11cca1f1e3b10143d6f7e1e7e92d2dd9aacbb1f875379d308e. +// WatchP2TRFraudRouterSet is a free log subscription operation binding the contract event 0x083fc37b87ed978d63df891a75b6e9ab20e73e33ae9fcab66416b8d014ceee54. // -// Solidity: event NewWalletRegistered(bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) -func (_Bridge *BridgeFilterer) WatchNewWalletRegistered(opts *bind.WatchOpts, sink chan<- *BridgeNewWalletRegistered, ecdsaWalletID [][32]byte, walletPubKeyHash [][20]byte) (event.Subscription, error) { - - var ecdsaWalletIDRule []interface{} - for _, ecdsaWalletIDItem := range ecdsaWalletID { - ecdsaWalletIDRule = append(ecdsaWalletIDRule, ecdsaWalletIDItem) - } - var walletPubKeyHashRule []interface{} - for _, walletPubKeyHashItem := range walletPubKeyHash { - walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) - } +// Solidity: event P2TRFraudRouterSet(address p2trFraudRouter) +func (_Bridge *BridgeFilterer) WatchP2TRFraudRouterSet(opts *bind.WatchOpts, sink chan<- *BridgeP2TRFraudRouterSet) (event.Subscription, error) { - logs, sub, err := _Bridge.contract.WatchLogs(opts, "NewWalletRegistered", ecdsaWalletIDRule, walletPubKeyHashRule) + logs, sub, err := _Bridge.contract.WatchLogs(opts, "P2TRFraudRouterSet") if err != nil { return nil, err } @@ -4389,8 +5956,8 @@ func (_Bridge *BridgeFilterer) WatchNewWalletRegistered(opts *bind.WatchOpts, si select { case log := <-logs: // New log arrived, parse the event and forward to the user - event := new(BridgeNewWalletRegistered) - if err := _Bridge.contract.UnpackLog(event, "NewWalletRegistered", log); err != nil { + event := new(BridgeP2TRFraudRouterSet) + if err := _Bridge.contract.UnpackLog(event, "P2TRFraudRouterSet", log); err != nil { return err } event.Raw = log @@ -4411,21 +5978,21 @@ func (_Bridge *BridgeFilterer) WatchNewWalletRegistered(opts *bind.WatchOpts, si }), nil } -// ParseNewWalletRegistered is a log parse operation binding the contract event 0x2dbb47dce81d6b11cca1f1e3b10143d6f7e1e7e92d2dd9aacbb1f875379d308e. +// ParseP2TRFraudRouterSet is a log parse operation binding the contract event 0x083fc37b87ed978d63df891a75b6e9ab20e73e33ae9fcab66416b8d014ceee54. // -// Solidity: event NewWalletRegistered(bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) -func (_Bridge *BridgeFilterer) ParseNewWalletRegistered(log types.Log) (*BridgeNewWalletRegistered, error) { - event := new(BridgeNewWalletRegistered) - if err := _Bridge.contract.UnpackLog(event, "NewWalletRegistered", log); err != nil { +// Solidity: event P2TRFraudRouterSet(address p2trFraudRouter) +func (_Bridge *BridgeFilterer) ParseP2TRFraudRouterSet(log types.Log) (*BridgeP2TRFraudRouterSet, error) { + event := new(BridgeP2TRFraudRouterSet) + if err := _Bridge.contract.UnpackLog(event, "P2TRFraudRouterSet", log); err != nil { return nil, err } event.Raw = log return event, nil } -// BridgeNewWalletRequestedIterator is returned from FilterNewWalletRequested and is used to iterate over the raw logs and unpacked data for NewWalletRequested events raised by the Bridge contract. -type BridgeNewWalletRequestedIterator struct { - Event *BridgeNewWalletRequested // Event containing the contract specifics and raw log +// BridgeRebateStakingSetIterator is returned from FilterRebateStakingSet and is used to iterate over the raw logs and unpacked data for RebateStakingSet events raised by the Bridge contract. +type BridgeRebateStakingSetIterator struct { + Event *BridgeRebateStakingSet // Event containing the contract specifics and raw log contract *bind.BoundContract // Generic contract to use for unpacking event data event string // Event name to use for unpacking event data @@ -4439,7 +6006,7 @@ type BridgeNewWalletRequestedIterator struct { // Next advances the iterator to the subsequent event, returning whether there // are any more events found. In case of a retrieval or parsing error, false is // returned and Error() can be queried for the exact failure. -func (it *BridgeNewWalletRequestedIterator) Next() bool { +func (it *BridgeRebateStakingSetIterator) Next() bool { // If the iterator failed, stop iterating if it.fail != nil { return false @@ -4448,7 +6015,7 @@ func (it *BridgeNewWalletRequestedIterator) Next() bool { if it.done { select { case log := <-it.logs: - it.Event = new(BridgeNewWalletRequested) + it.Event = new(BridgeRebateStakingSet) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -4463,7 +6030,7 @@ func (it *BridgeNewWalletRequestedIterator) Next() bool { // Iterator still in progress, wait for either a data or an error event select { case log := <-it.logs: - it.Event = new(BridgeNewWalletRequested) + it.Event = new(BridgeRebateStakingSet) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -4479,40 +6046,41 @@ func (it *BridgeNewWalletRequestedIterator) Next() bool { } // Error returns any retrieval or parsing error occurred during filtering. -func (it *BridgeNewWalletRequestedIterator) Error() error { +func (it *BridgeRebateStakingSetIterator) Error() error { return it.fail } // Close terminates the iteration process, releasing any pending underlying // resources. -func (it *BridgeNewWalletRequestedIterator) Close() error { +func (it *BridgeRebateStakingSetIterator) Close() error { it.sub.Unsubscribe() return nil } -// BridgeNewWalletRequested represents a NewWalletRequested event raised by the Bridge contract. -type BridgeNewWalletRequested struct { - Raw types.Log // Blockchain specific contextual infos +// BridgeRebateStakingSet represents a RebateStakingSet event raised by the Bridge contract. +type BridgeRebateStakingSet struct { + RebateStaking common.Address + Raw types.Log // Blockchain specific contextual infos } -// FilterNewWalletRequested is a free log retrieval operation binding the contract event 0x31fecb80caf1e1128496dd5a6f1083ba29fd5fe64c3fe04e2d1b6f9cfc27d5a3. +// FilterRebateStakingSet is a free log retrieval operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. // -// Solidity: event NewWalletRequested() -func (_Bridge *BridgeFilterer) FilterNewWalletRequested(opts *bind.FilterOpts) (*BridgeNewWalletRequestedIterator, error) { +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) FilterRebateStakingSet(opts *bind.FilterOpts) (*BridgeRebateStakingSetIterator, error) { - logs, sub, err := _Bridge.contract.FilterLogs(opts, "NewWalletRequested") + logs, sub, err := _Bridge.contract.FilterLogs(opts, "RebateStakingSet") if err != nil { return nil, err } - return &BridgeNewWalletRequestedIterator{contract: _Bridge.contract, event: "NewWalletRequested", logs: logs, sub: sub}, nil + return &BridgeRebateStakingSetIterator{contract: _Bridge.contract, event: "RebateStakingSet", logs: logs, sub: sub}, nil } -// WatchNewWalletRequested is a free log subscription operation binding the contract event 0x31fecb80caf1e1128496dd5a6f1083ba29fd5fe64c3fe04e2d1b6f9cfc27d5a3. +// WatchRebateStakingSet is a free log subscription operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. // -// Solidity: event NewWalletRequested() -func (_Bridge *BridgeFilterer) WatchNewWalletRequested(opts *bind.WatchOpts, sink chan<- *BridgeNewWalletRequested) (event.Subscription, error) { +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) WatchRebateStakingSet(opts *bind.WatchOpts, sink chan<- *BridgeRebateStakingSet) (event.Subscription, error) { - logs, sub, err := _Bridge.contract.WatchLogs(opts, "NewWalletRequested") + logs, sub, err := _Bridge.contract.WatchLogs(opts, "RebateStakingSet") if err != nil { return nil, err } @@ -4522,8 +6090,8 @@ func (_Bridge *BridgeFilterer) WatchNewWalletRequested(opts *bind.WatchOpts, sin select { case log := <-logs: // New log arrived, parse the event and forward to the user - event := new(BridgeNewWalletRequested) - if err := _Bridge.contract.UnpackLog(event, "NewWalletRequested", log); err != nil { + event := new(BridgeRebateStakingSet) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingSet", log); err != nil { return err } event.Raw = log @@ -4544,12 +6112,12 @@ func (_Bridge *BridgeFilterer) WatchNewWalletRequested(opts *bind.WatchOpts, sin }), nil } -// ParseNewWalletRequested is a log parse operation binding the contract event 0x31fecb80caf1e1128496dd5a6f1083ba29fd5fe64c3fe04e2d1b6f9cfc27d5a3. +// ParseRebateStakingSet is a log parse operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. // -// Solidity: event NewWalletRequested() -func (_Bridge *BridgeFilterer) ParseNewWalletRequested(log types.Log) (*BridgeNewWalletRequested, error) { - event := new(BridgeNewWalletRequested) - if err := _Bridge.contract.UnpackLog(event, "NewWalletRequested", log); err != nil { +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) ParseRebateStakingSet(log types.Log) (*BridgeRebateStakingSet, error) { + event := new(BridgeRebateStakingSet) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingSet", log); err != nil { return nil, err } event.Raw = log @@ -5424,6 +6992,170 @@ func (_Bridge *BridgeFilterer) ParseSpvMaintainerStatusUpdated(log types.Log) (* return event, nil } +// BridgeTaprootDepositRevealedIterator is returned from FilterTaprootDepositRevealed and is used to iterate over the raw logs and unpacked data for TaprootDepositRevealed events raised by the Bridge contract. +type BridgeTaprootDepositRevealedIterator struct { + Event *BridgeTaprootDepositRevealed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeTaprootDepositRevealedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeTaprootDepositRevealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeTaprootDepositRevealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeTaprootDepositRevealedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeTaprootDepositRevealedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeTaprootDepositRevealed represents a TaprootDepositRevealed event raised by the Bridge contract. +type BridgeTaprootDepositRevealed struct { + FundingTxHash [32]byte + FundingOutputIndex uint32 + Depositor common.Address + Amount uint64 + BlindingFactor [8]byte + WalletPubKeyHash [20]byte + WalletXOnlyPublicKey [32]byte + RefundPubKeyHash [20]byte + RefundXOnlyPublicKey [32]byte + RefundLocktime [4]byte + Vault common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTaprootDepositRevealed is a free log retrieval operation binding the contract event 0x50a25b08826caa8347dba14b236afbf87a8988553a910cbc953f1a53585d94cf. +// +// Solidity: event TaprootDepositRevealed(bytes32 fundingTxHash, uint32 fundingOutputIndex, address indexed depositor, uint64 amount, bytes8 blindingFactor, bytes20 indexed walletPubKeyHash, bytes32 walletXOnlyPublicKey, bytes20 refundPubKeyHash, bytes32 refundXOnlyPublicKey, bytes4 refundLocktime, address vault) +func (_Bridge *BridgeFilterer) FilterTaprootDepositRevealed(opts *bind.FilterOpts, depositor []common.Address, walletPubKeyHash [][20]byte) (*BridgeTaprootDepositRevealedIterator, error) { + + var depositorRule []interface{} + for _, depositorItem := range depositor { + depositorRule = append(depositorRule, depositorItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "TaprootDepositRevealed", depositorRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return &BridgeTaprootDepositRevealedIterator{contract: _Bridge.contract, event: "TaprootDepositRevealed", logs: logs, sub: sub}, nil +} + +// WatchTaprootDepositRevealed is a free log subscription operation binding the contract event 0x50a25b08826caa8347dba14b236afbf87a8988553a910cbc953f1a53585d94cf. +// +// Solidity: event TaprootDepositRevealed(bytes32 fundingTxHash, uint32 fundingOutputIndex, address indexed depositor, uint64 amount, bytes8 blindingFactor, bytes20 indexed walletPubKeyHash, bytes32 walletXOnlyPublicKey, bytes20 refundPubKeyHash, bytes32 refundXOnlyPublicKey, bytes4 refundLocktime, address vault) +func (_Bridge *BridgeFilterer) WatchTaprootDepositRevealed(opts *bind.WatchOpts, sink chan<- *BridgeTaprootDepositRevealed, depositor []common.Address, walletPubKeyHash [][20]byte) (event.Subscription, error) { + + var depositorRule []interface{} + for _, depositorItem := range depositor { + depositorRule = append(depositorRule, depositorItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "TaprootDepositRevealed", depositorRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeTaprootDepositRevealed) + if err := _Bridge.contract.UnpackLog(event, "TaprootDepositRevealed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTaprootDepositRevealed is a log parse operation binding the contract event 0x50a25b08826caa8347dba14b236afbf87a8988553a910cbc953f1a53585d94cf. +// +// Solidity: event TaprootDepositRevealed(bytes32 fundingTxHash, uint32 fundingOutputIndex, address indexed depositor, uint64 amount, bytes8 blindingFactor, bytes20 indexed walletPubKeyHash, bytes32 walletXOnlyPublicKey, bytes20 refundPubKeyHash, bytes32 refundXOnlyPublicKey, bytes4 refundLocktime, address vault) +func (_Bridge *BridgeFilterer) ParseTaprootDepositRevealed(log types.Log) (*BridgeTaprootDepositRevealed, error) { + event := new(BridgeTaprootDepositRevealed) + if err := _Bridge.contract.UnpackLog(event, "TaprootDepositRevealed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BridgeTreasuryUpdatedIterator is returned from FilterTreasuryUpdated and is used to iterate over the raw logs and unpacked data for TreasuryUpdated events raised by the Bridge contract. type BridgeTreasuryUpdatedIterator struct { Event *BridgeTreasuryUpdated // Event containing the contract specifics and raw log diff --git a/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go b/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go index ed86d98785..21f79319ba 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go @@ -95,9 +95,20 @@ type WalletProposalValidatorRedemptionProposal struct { RedemptionTxFee *big.Int } +// WalletProposalValidatorTaprootDepositExtraInfo is an auto generated low-level Go binding around an user-defined struct. +type WalletProposalValidatorTaprootDepositExtraInfo struct { + FundingTx BitcoinTxInfo2 + BlindingFactor [8]byte + WalletPubKeyHash [20]byte + WalletXOnlyPublicKey [32]byte + RefundPubKeyHash [20]byte + RefundXOnlyPublicKey [32]byte + RefundLocktime [4]byte +} + // WalletProposalValidatorMetaData contains all meta data concerning the WalletProposalValidator contract. var WalletProposalValidatorMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"DEPOSIT_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_REFUND_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_SWEEP_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"}],\"internalType\":\"structWalletProposalValidator.DepositKey[]\",\"name\":\"depositsKeys\",\"type\":\"tuple[]\"},{\"internalType\":\"uint256\",\"name\":\"sweepTxFee\",\"type\":\"uint256\"},{\"internalType\":\"uint256[]\",\"name\":\"depositsRevealBlocks\",\"type\":\"uint256[]\"}],\"internalType\":\"structWalletProposalValidator.DepositSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"}],\"internalType\":\"structWalletProposalValidator.DepositExtraInfo[]\",\"name\":\"depositsExtraInfo\",\"type\":\"tuple[]\"}],\"name\":\"validateDepositSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"message\",\"type\":\"bytes\"}],\"internalType\":\"structWalletProposalValidator.HeartbeatProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateHeartbeatProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint256\",\"name\":\"movedFundsSweepTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovedFundsSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateMovedFundsSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"internalType\":\"uint256\",\"name\":\"movingFundsTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovingFundsProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"validateMovingFundsProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes[]\",\"name\":\"redeemersOutputScripts\",\"type\":\"bytes[]\"},{\"internalType\":\"uint256\",\"name\":\"redemptionTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.RedemptionProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateRedemptionProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"DEPOSIT_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_REFUND_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_SWEEP_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"}],\"internalType\":\"structWalletProposalValidator.DepositKey[]\",\"name\":\"depositsKeys\",\"type\":\"tuple[]\"},{\"internalType\":\"uint256\",\"name\":\"sweepTxFee\",\"type\":\"uint256\"},{\"internalType\":\"uint256[]\",\"name\":\"depositsRevealBlocks\",\"type\":\"uint256[]\"}],\"internalType\":\"structWalletProposalValidator.DepositSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"}],\"internalType\":\"structWalletProposalValidator.DepositExtraInfo[]\",\"name\":\"depositsExtraInfo\",\"type\":\"tuple[]\"}],\"name\":\"validateDepositSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"message\",\"type\":\"bytes\"}],\"internalType\":\"structWalletProposalValidator.HeartbeatProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateHeartbeatProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint256\",\"name\":\"movedFundsSweepTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovedFundsSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateMovedFundsSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"internalType\":\"uint256\",\"name\":\"movingFundsTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovingFundsProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"validateMovingFundsProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes[]\",\"name\":\"redeemersOutputScripts\",\"type\":\"bytes[]\"},{\"internalType\":\"uint256\",\"name\":\"redemptionTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.RedemptionProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateRedemptionProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"}],\"internalType\":\"structWalletProposalValidator.DepositKey[]\",\"name\":\"depositsKeys\",\"type\":\"tuple[]\"},{\"internalType\":\"uint256\",\"name\":\"sweepTxFee\",\"type\":\"uint256\"},{\"internalType\":\"uint256[]\",\"name\":\"depositsRevealBlocks\",\"type\":\"uint256[]\"}],\"internalType\":\"structWalletProposalValidator.DepositSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"walletXOnlyPublicKey\",\"type\":\"bytes32\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"refundXOnlyPublicKey\",\"type\":\"bytes32\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"}],\"internalType\":\"structWalletProposalValidator.TaprootDepositExtraInfo[]\",\"name\":\"depositsExtraInfo\",\"type\":\"tuple[]\"}],\"name\":\"validateTaprootDepositSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", } // WalletProposalValidatorABI is the input ABI used to generate the binding from. @@ -617,3 +628,34 @@ func (_WalletProposalValidator *WalletProposalValidatorSession) ValidateRedempti func (_WalletProposalValidator *WalletProposalValidatorCallerSession) ValidateRedemptionProposal(proposal WalletProposalValidatorRedemptionProposal) (bool, error) { return _WalletProposalValidator.Contract.ValidateRedemptionProposal(&_WalletProposalValidator.CallOpts, proposal) } + +// ValidateTaprootDepositSweepProposal is a free data retrieval call binding the contract method 0xb1782302. +// +// Solidity: function validateTaprootDepositSweepProposal((bytes20,(bytes32,uint32)[],uint256,uint256[]) proposal, ((bytes4,bytes,bytes,bytes4),bytes8,bytes20,bytes32,bytes20,bytes32,bytes4)[] depositsExtraInfo) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCaller) ValidateTaprootDepositSweepProposal(opts *bind.CallOpts, proposal WalletProposalValidatorDepositSweepProposal, depositsExtraInfo []WalletProposalValidatorTaprootDepositExtraInfo) (bool, error) { + var out []interface{} + err := _WalletProposalValidator.contract.Call(opts, &out, "validateTaprootDepositSweepProposal", proposal, depositsExtraInfo) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// ValidateTaprootDepositSweepProposal is a free data retrieval call binding the contract method 0xb1782302. +// +// Solidity: function validateTaprootDepositSweepProposal((bytes20,(bytes32,uint32)[],uint256,uint256[]) proposal, ((bytes4,bytes,bytes,bytes4),bytes8,bytes20,bytes32,bytes20,bytes32,bytes4)[] depositsExtraInfo) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorSession) ValidateTaprootDepositSweepProposal(proposal WalletProposalValidatorDepositSweepProposal, depositsExtraInfo []WalletProposalValidatorTaprootDepositExtraInfo) (bool, error) { + return _WalletProposalValidator.Contract.ValidateTaprootDepositSweepProposal(&_WalletProposalValidator.CallOpts, proposal, depositsExtraInfo) +} + +// ValidateTaprootDepositSweepProposal is a free data retrieval call binding the contract method 0xb1782302. +// +// Solidity: function validateTaprootDepositSweepProposal((bytes20,(bytes32,uint32)[],uint256,uint256[]) proposal, ((bytes4,bytes,bytes,bytes4),bytes8,bytes20,bytes32,bytes20,bytes32,bytes4)[] depositsExtraInfo) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCallerSession) ValidateTaprootDepositSweepProposal(proposal WalletProposalValidatorDepositSweepProposal, depositsExtraInfo []WalletProposalValidatorTaprootDepositExtraInfo) (bool, error) { + return _WalletProposalValidator.Contract.ValidateTaprootDepositSweepProposal(&_WalletProposalValidator.CallOpts, proposal, depositsExtraInfo) +} diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go b/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go index f7a5944669..058deabc58 100644 --- a/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go @@ -52,31 +52,38 @@ func init() { } BridgeCommand.AddCommand( + bActiveWalletIDCommand(), bActiveWalletPubKeyHashCommand(), bContractReferencesCommand(), bDepositParametersCommand(), bDepositsCommand(), - bFraudChallengesCommand(), + bEcdsaFraudRouterCommand(), + bEcdsaRetiredCommand(), bFraudParametersCommand(), + bFrostLifecycleContextCommand(), + bGetRebateStakingCommand(), bGetRedemptionWatchtowerCommand(), bGovernanceCommand(), bIsVaultTrustedCommand(), bLiveWalletsCountCommand(), bMovedFundsSweepRequestsCommand(), bMovingFundsParametersCommand(), + bP2trFraudRouterCommand(), bPendingRedemptionsCommand(), bRedemptionParametersCommand(), bSpentMainUTXOsCommand(), bTimedOutRedemptionsCommand(), bTreasuryCommand(), bTxProofDifficultyFactorCommand(), + bWalletIDCommand(), bWalletParametersCommand(), + bWalletPubKeyHashForWalletIDCommand(), bWalletsCommand(), - bDefeatFraudChallengeCommand(), - bDefeatFraudChallengeWithHeartbeatCommand(), - bEcdsaWalletCreatedCallbackCommand(), + bWalletsByWalletIDCommand(), bEcdsaWalletHeartbeatFailedCallbackCommand(), + bFrostWalletCreatedCallbackCommand(), bInitializeCommand(), + bInitializeV2FixVaultZeroDepositCommand(), bNotifyMovingFundsBelowDustCommand(), bNotifyRedemptionVetoCommand(), bNotifyWalletCloseableCommand(), @@ -85,13 +92,20 @@ func init() { bRequestNewWalletCommand(), bRequestRedemptionCommand(), bResetMovingFundsTimeoutCommand(), + bRetireEcdsaCommand(), bRevealDepositCommand(), bRevealDepositWithExtraDataCommand(), + bRevealTaprootDepositCommand(), + bRevealTaprootDepositWithExtraDataCommand(), + bSetEcdsaFraudRouterCommand(), + bSetFrostWalletRegistryCommand(), + bSetLifecycleRouterCommand(), + bSetP2TRFraudRouterCommand(), + bSetRebateStakingCommand(), bSetRedemptionWatchtowerCommand(), bSetSpvMaintainerStatusCommand(), bSetVaultStatusCommand(), bSubmitDepositSweepProofCommand(), - bSubmitFraudChallengeCommand(), bSubmitMovedFundsSweepProofCommand(), bSubmitMovingFundsProofCommand(), bSubmitRedemptionProofCommand(), @@ -109,6 +123,40 @@ func init() { /// ------------------- Const methods ------------------- +func bActiveWalletIDCommand() *cobra.Command { + c := &cobra.Command{ + Use: "active-wallet-i-d", + Short: "Calls the view method activeWalletID on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bActiveWalletID, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bActiveWalletID(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + result, err := contract.ActiveWalletIDAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bActiveWalletPubKeyHashCommand() *cobra.Command { c := &cobra.Command{ Use: "active-wallet-pub-key-hash", @@ -254,12 +302,12 @@ func bDeposits(c *cobra.Command, args []string) error { return nil } -func bFraudChallengesCommand() *cobra.Command { +func bEcdsaFraudRouterCommand() *cobra.Command { c := &cobra.Command{ - Use: "fraud-challenges [arg_challengeKey]", - Short: "Calls the view method fraudChallenges on the Bridge contract.", - Args: cmd.ArgCountChecker(1), - RunE: bFraudChallenges, + Use: "ecdsa-fraud-router", + Short: "Calls the view method ecdsaFraudRouter on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bEcdsaFraudRouter, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -269,22 +317,47 @@ func bFraudChallengesCommand() *cobra.Command { return c } -func bFraudChallenges(c *cobra.Command, args []string) error { +func bEcdsaFraudRouter(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_challengeKey, err := hexutil.DecodeBig(args[0]) + result, err := contract.EcdsaFraudRouterAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func bEcdsaRetiredCommand() *cobra.Command { + c := &cobra.Command{ + Use: "ecdsa-retired", + Short: "Calls the view method ecdsaRetired on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bEcdsaRetired, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bEcdsaRetired(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) if err != nil { - return fmt.Errorf( - "couldn't parse parameter arg_challengeKey, a uint256, from passed value %v", - args[0], - ) + return err } - result, err := contract.FraudChallengesAtBlock( - arg_challengeKey, + result, err := contract.EcdsaRetiredAtBlock( cmd.BlockFlagValue.Int, ) @@ -331,6 +404,83 @@ func bFraudParameters(c *cobra.Command, args []string) error { return nil } +func bFrostLifecycleContextCommand() *cobra.Command { + c := &cobra.Command{ + Use: "frost-lifecycle-context [arg_walletPubKeyHash]", + Short: "Calls the view method frostLifecycleContext on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bFrostLifecycleContext, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bFrostLifecycleContext(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[0], + ) + } + + result, err := contract.FrostLifecycleContextAtBlock( + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func bGetRebateStakingCommand() *cobra.Command { + c := &cobra.Command{ + Use: "get-rebate-staking", + Short: "Calls the view method getRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bGetRebateStaking, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bGetRebateStaking(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + result, err := contract.GetRebateStakingAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bGetRedemptionWatchtowerCommand() *cobra.Command { c := &cobra.Command{ Use: "get-redemption-watchtower", @@ -553,6 +703,40 @@ func bMovingFundsParameters(c *cobra.Command, args []string) error { return nil } +func bP2trFraudRouterCommand() *cobra.Command { + c := &cobra.Command{ + Use: "p2tr-fraud-router", + Short: "Calls the view method p2trFraudRouter on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bP2trFraudRouter, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bP2trFraudRouter(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + result, err := contract.P2trFraudRouterAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bPendingRedemptionsCommand() *cobra.Command { c := &cobra.Command{ Use: "pending-redemptions [arg_redemptionKey]", @@ -784,12 +968,12 @@ func bTxProofDifficultyFactor(c *cobra.Command, args []string) error { return nil } -func bWalletParametersCommand() *cobra.Command { +func bWalletIDCommand() *cobra.Command { c := &cobra.Command{ - Use: "wallet-parameters", - Short: "Calls the view method walletParameters on the Bridge contract.", - Args: cmd.ArgCountChecker(0), - RunE: bWalletParameters, + Use: "wallet-i-d [arg_walletPubKeyHash]", + Short: "Calls the view method walletID on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bWalletID, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -799,13 +983,22 @@ func bWalletParametersCommand() *cobra.Command { return c } -func bWalletParameters(c *cobra.Command, args []string) error { +func bWalletID(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - result, err := contract.WalletParametersAtBlock( + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletIDAtBlock( + arg_walletPubKeyHash, cmd.BlockFlagValue.Int, ) @@ -818,12 +1011,12 @@ func bWalletParameters(c *cobra.Command, args []string) error { return nil } -func bWalletsCommand() *cobra.Command { +func bWalletParametersCommand() *cobra.Command { c := &cobra.Command{ - Use: "wallets [arg_walletPubKeyHash]", - Short: "Calls the view method wallets on the Bridge contract.", - Args: cmd.ArgCountChecker(1), - RunE: bWallets, + Use: "wallet-parameters", + Short: "Calls the view method walletParameters on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bWalletParameters, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -833,22 +1026,13 @@ func bWalletsCommand() *cobra.Command { return c } -func bWallets(c *cobra.Command, args []string) error { +func bWalletParameters(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) - if err != nil { - return fmt.Errorf( - "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", - args[0], - ) - } - - result, err := contract.WalletsAtBlock( - arg_walletPubKeyHash, + result, err := contract.WalletParametersAtBlock( cmd.BlockFlagValue.Int, ) @@ -861,171 +1045,143 @@ func bWallets(c *cobra.Command, args []string) error { return nil } -/// ------------------- Non-const methods ------------------- - -func bDefeatFraudChallengeCommand() *cobra.Command { +func bWalletPubKeyHashForWalletIDCommand() *cobra.Command { c := &cobra.Command{ - Use: "defeat-fraud-challenge [arg_walletPublicKey] [arg_preimage] [arg_witness]", - Short: "Calls the nonpayable method defeatFraudChallenge on the Bridge contract.", - Args: cmd.ArgCountChecker(3), - RunE: bDefeatFraudChallenge, + Use: "wallet-pub-key-hash-for-wallet-i-d [arg_walletId]", + Short: "Calls the view method walletPubKeyHashForWalletID on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bWalletPubKeyHashForWalletID, SilenceUsage: true, DisableFlagsInUseLine: true, } - c.PreRunE = cmd.NonConstArgsChecker - cmd.InitNonConstFlags(c) + cmd.InitConstFlags(c) return c } -func bDefeatFraudChallenge(c *cobra.Command, args []string) error { +func bWalletPubKeyHashForWalletID(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_walletPublicKey, err := hexutil.Decode(args[0]) + arg_walletId, err := decode.ParseBytes32(args[0]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg_walletPublicKey, a bytes, from passed value %v", + "couldn't parse parameter arg_walletId, a bytes32, from passed value %v", args[0], ) } - arg_preimage, err := hexutil.Decode(args[1]) - if err != nil { - return fmt.Errorf( - "couldn't parse parameter arg_preimage, a bytes, from passed value %v", - args[1], - ) - } - arg_witness, err := strconv.ParseBool(args[2]) - if err != nil { - return fmt.Errorf( - "couldn't parse parameter arg_witness, a bool, from passed value %v", - args[2], - ) - } - var ( - transaction *types.Transaction + result, err := contract.WalletPubKeyHashForWalletIDAtBlock( + arg_walletId, + cmd.BlockFlagValue.Int, ) - if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { - // Do a regular submission. Take payable into account. - transaction, err = contract.DefeatFraudChallenge( - arg_walletPublicKey, - arg_preimage, - arg_witness, - ) - if err != nil { - return err - } - - cmd.PrintOutput(transaction.Hash()) - } else { - // Do a call. - err = contract.CallDefeatFraudChallenge( - arg_walletPublicKey, - arg_preimage, - arg_witness, - cmd.BlockFlagValue.Int, - ) - if err != nil { - return err - } - - cmd.PrintOutput("success") - - cmd.PrintOutput( - "the transaction was not submitted to the chain; " + - "please add the `--submit` flag", - ) + if err != nil { + return err } + cmd.PrintOutput(result) + return nil } -func bDefeatFraudChallengeWithHeartbeatCommand() *cobra.Command { +func bWalletsCommand() *cobra.Command { c := &cobra.Command{ - Use: "defeat-fraud-challenge-with-heartbeat [arg_walletPublicKey] [arg_heartbeatMessage]", - Short: "Calls the nonpayable method defeatFraudChallengeWithHeartbeat on the Bridge contract.", - Args: cmd.ArgCountChecker(2), - RunE: bDefeatFraudChallengeWithHeartbeat, + Use: "wallets [arg_walletPubKeyHash]", + Short: "Calls the view method wallets on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bWallets, SilenceUsage: true, DisableFlagsInUseLine: true, } - c.PreRunE = cmd.NonConstArgsChecker - cmd.InitNonConstFlags(c) + cmd.InitConstFlags(c) return c } -func bDefeatFraudChallengeWithHeartbeat(c *cobra.Command, args []string) error { +func bWallets(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_walletPublicKey, err := hexutil.Decode(args[0]) + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg_walletPublicKey, a bytes, from passed value %v", + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", args[0], ) } - arg_heartbeatMessage, err := hexutil.Decode(args[1]) - if err != nil { - return fmt.Errorf( - "couldn't parse parameter arg_heartbeatMessage, a bytes, from passed value %v", - args[1], - ) - } - var ( - transaction *types.Transaction + result, err := contract.WalletsAtBlock( + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, ) - if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { - // Do a regular submission. Take payable into account. - transaction, err = contract.DefeatFraudChallengeWithHeartbeat( - arg_walletPublicKey, - arg_heartbeatMessage, - ) - if err != nil { - return err - } + if err != nil { + return err + } - cmd.PrintOutput(transaction.Hash()) - } else { - // Do a call. - err = contract.CallDefeatFraudChallengeWithHeartbeat( - arg_walletPublicKey, - arg_heartbeatMessage, - cmd.BlockFlagValue.Int, - ) - if err != nil { - return err - } + cmd.PrintOutput(result) - cmd.PrintOutput("success") + return nil +} - cmd.PrintOutput( - "the transaction was not submitted to the chain; " + - "please add the `--submit` flag", +func bWalletsByWalletIDCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallets-by-wallet-i-d [arg_walletId]", + Short: "Calls the view method walletsByWalletID on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bWalletsByWalletID, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bWalletsByWalletID(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_walletId, err := decode.ParseBytes32(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletId, a bytes32, from passed value %v", + args[0], ) } + result, err := contract.WalletsByWalletIDAtBlock( + arg_walletId, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + return nil } -func bEcdsaWalletCreatedCallbackCommand() *cobra.Command { +/// ------------------- Non-const methods ------------------- + +func bEcdsaWalletHeartbeatFailedCallbackCommand() *cobra.Command { c := &cobra.Command{ - Use: "ecdsa-wallet-created-callback [arg_ecdsaWalletID] [arg_publicKeyX] [arg_publicKeyY]", - Short: "Calls the nonpayable method ecdsaWalletCreatedCallback on the Bridge contract.", + Use: "ecdsa-wallet-heartbeat-failed-callback [arg0] [arg_publicKeyX] [arg_publicKeyY]", + Short: "Calls the nonpayable method ecdsaWalletHeartbeatFailedCallback on the Bridge contract.", Args: cmd.ArgCountChecker(3), - RunE: bEcdsaWalletCreatedCallback, + RunE: bEcdsaWalletHeartbeatFailedCallback, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -1036,16 +1192,16 @@ func bEcdsaWalletCreatedCallbackCommand() *cobra.Command { return c } -func bEcdsaWalletCreatedCallback(c *cobra.Command, args []string) error { +func bEcdsaWalletHeartbeatFailedCallback(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_ecdsaWalletID, err := decode.ParseBytes32(args[0]) + arg0, err := decode.ParseBytes32(args[0]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg_ecdsaWalletID, a bytes32, from passed value %v", + "couldn't parse parameter arg0, a bytes32, from passed value %v", args[0], ) } @@ -1070,8 +1226,8 @@ func bEcdsaWalletCreatedCallback(c *cobra.Command, args []string) error { if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { // Do a regular submission. Take payable into account. - transaction, err = contract.EcdsaWalletCreatedCallback( - arg_ecdsaWalletID, + transaction, err = contract.EcdsaWalletHeartbeatFailedCallback( + arg0, arg_publicKeyX, arg_publicKeyY, ) @@ -1082,8 +1238,8 @@ func bEcdsaWalletCreatedCallback(c *cobra.Command, args []string) error { cmd.PrintOutput(transaction.Hash()) } else { // Do a call. - err = contract.CallEcdsaWalletCreatedCallback( - arg_ecdsaWalletID, + err = contract.CallEcdsaWalletHeartbeatFailedCallback( + arg0, arg_publicKeyX, arg_publicKeyY, cmd.BlockFlagValue.Int, @@ -1103,12 +1259,12 @@ func bEcdsaWalletCreatedCallback(c *cobra.Command, args []string) error { return nil } -func bEcdsaWalletHeartbeatFailedCallbackCommand() *cobra.Command { +func bFrostWalletCreatedCallbackCommand() *cobra.Command { c := &cobra.Command{ - Use: "ecdsa-wallet-heartbeat-failed-callback [arg0] [arg_publicKeyX] [arg_publicKeyY]", - Short: "Calls the nonpayable method ecdsaWalletHeartbeatFailedCallback on the Bridge contract.", - Args: cmd.ArgCountChecker(3), - RunE: bEcdsaWalletHeartbeatFailedCallback, + Use: "frost-wallet-created-callback [arg_xOnlyOutputKey]", + Short: "Calls the nonpayable method frostWalletCreatedCallback on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bFrostWalletCreatedCallback, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -1119,33 +1275,19 @@ func bEcdsaWalletHeartbeatFailedCallbackCommand() *cobra.Command { return c } -func bEcdsaWalletHeartbeatFailedCallback(c *cobra.Command, args []string) error { +func bFrostWalletCreatedCallback(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg0, err := decode.ParseBytes32(args[0]) + arg_xOnlyOutputKey, err := decode.ParseBytes32(args[0]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg0, a bytes32, from passed value %v", + "couldn't parse parameter arg_xOnlyOutputKey, a bytes32, from passed value %v", args[0], ) } - arg_publicKeyX, err := decode.ParseBytes32(args[1]) - if err != nil { - return fmt.Errorf( - "couldn't parse parameter arg_publicKeyX, a bytes32, from passed value %v", - args[1], - ) - } - arg_publicKeyY, err := decode.ParseBytes32(args[2]) - if err != nil { - return fmt.Errorf( - "couldn't parse parameter arg_publicKeyY, a bytes32, from passed value %v", - args[2], - ) - } var ( transaction *types.Transaction @@ -1153,10 +1295,8 @@ func bEcdsaWalletHeartbeatFailedCallback(c *cobra.Command, args []string) error if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { // Do a regular submission. Take payable into account. - transaction, err = contract.EcdsaWalletHeartbeatFailedCallback( - arg0, - arg_publicKeyX, - arg_publicKeyY, + transaction, err = contract.FrostWalletCreatedCallback( + arg_xOnlyOutputKey, ) if err != nil { return err @@ -1165,10 +1305,8 @@ func bEcdsaWalletHeartbeatFailedCallback(c *cobra.Command, args []string) error cmd.PrintOutput(transaction.Hash()) } else { // Do a call. - err = contract.CallEcdsaWalletHeartbeatFailedCallback( - arg0, - arg_publicKeyX, - arg_publicKeyY, + err = contract.CallFrostWalletCreatedCallback( + arg_xOnlyOutputKey, cmd.BlockFlagValue.Int, ) if err != nil { @@ -1296,6 +1434,60 @@ func bInitialize(c *cobra.Command, args []string) error { return nil } +func bInitializeV2FixVaultZeroDepositCommand() *cobra.Command { + c := &cobra.Command{ + Use: "initialize-v2-fix-vault-zero-deposit", + Short: "Calls the nonpayable method initializeV2FixVaultZeroDeposit on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bInitializeV2FixVaultZeroDeposit, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bInitializeV2FixVaultZeroDeposit(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.InitializeV2FixVaultZeroDeposit() + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallInitializeV2FixVaultZeroDeposit( + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bNotifyMovingFundsBelowDustCommand() *cobra.Command { c := &cobra.Command{ Use: "notify-moving-funds-below-dust [arg_walletPubKeyHash] [arg_mainUtxo_json]", @@ -1879,6 +2071,60 @@ func bResetMovingFundsTimeout(c *cobra.Command, args []string) error { return nil } +func bRetireEcdsaCommand() *cobra.Command { + c := &cobra.Command{ + Use: "retire-ecdsa", + Short: "Calls the nonpayable method retireEcdsa on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bRetireEcdsa, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bRetireEcdsa(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.RetireEcdsa() + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallRetireEcdsa( + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bRevealDepositCommand() *cobra.Command { c := &cobra.Command{ Use: "reveal-deposit [arg_fundingTx_json] [arg_reveal_json]", @@ -2026,12 +2272,12 @@ func bRevealDepositWithExtraData(c *cobra.Command, args []string) error { return nil } -func bSetRedemptionWatchtowerCommand() *cobra.Command { +func bRevealTaprootDepositCommand() *cobra.Command { c := &cobra.Command{ - Use: "set-redemption-watchtower [arg_redemptionWatchtower]", - Short: "Calls the nonpayable method setRedemptionWatchtower on the Bridge contract.", - Args: cmd.ArgCountChecker(1), - RunE: bSetRedemptionWatchtower, + Use: "reveal-taproot-deposit [arg_fundingTx_json] [arg_reveal_json]", + Short: "Calls the nonpayable method revealTaprootDeposit on the Bridge contract.", + Args: cmd.ArgCountChecker(2), + RunE: bRevealTaprootDeposit, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -2042,18 +2288,20 @@ func bSetRedemptionWatchtowerCommand() *cobra.Command { return c } -func bSetRedemptionWatchtower(c *cobra.Command, args []string) error { +func bRevealTaprootDeposit(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_redemptionWatchtower, err := chainutil.AddressFromHex(args[0]) - if err != nil { - return fmt.Errorf( - "couldn't parse parameter arg_redemptionWatchtower, a address, from passed value %v", - args[0], - ) + arg_fundingTx_json := abi.BitcoinTxInfo{} + if err := json.Unmarshal([]byte(args[0]), &arg_fundingTx_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_fundingTx_json to abi.BitcoinTxInfo: %w", err) + } + + arg_reveal_json := abi.DepositTaprootDepositRevealInfo{} + if err := json.Unmarshal([]byte(args[1]), &arg_reveal_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_reveal_json to abi.DepositTaprootDepositRevealInfo: %w", err) } var ( @@ -2062,8 +2310,9 @@ func bSetRedemptionWatchtower(c *cobra.Command, args []string) error { if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { // Do a regular submission. Take payable into account. - transaction, err = contract.SetRedemptionWatchtower( - arg_redemptionWatchtower, + transaction, err = contract.RevealTaprootDeposit( + arg_fundingTx_json, + arg_reveal_json, ) if err != nil { return err @@ -2072,8 +2321,9 @@ func bSetRedemptionWatchtower(c *cobra.Command, args []string) error { cmd.PrintOutput(transaction.Hash()) } else { // Do a call. - err = contract.CallSetRedemptionWatchtower( - arg_redemptionWatchtower, + err = contract.CallRevealTaprootDeposit( + arg_fundingTx_json, + arg_reveal_json, cmd.BlockFlagValue.Int, ) if err != nil { @@ -2091,12 +2341,12 @@ func bSetRedemptionWatchtower(c *cobra.Command, args []string) error { return nil } -func bSetSpvMaintainerStatusCommand() *cobra.Command { +func bRevealTaprootDepositWithExtraDataCommand() *cobra.Command { c := &cobra.Command{ - Use: "set-spv-maintainer-status [arg_spvMaintainer] [arg_isTrusted]", - Short: "Calls the nonpayable method setSpvMaintainerStatus on the Bridge contract.", - Args: cmd.ArgCountChecker(2), - RunE: bSetSpvMaintainerStatus, + Use: "reveal-taproot-deposit-with-extra-data [arg_fundingTx_json] [arg_reveal_json] [arg_extraData]", + Short: "Calls the nonpayable method revealTaprootDepositWithExtraData on the Bridge contract.", + Args: cmd.ArgCountChecker(3), + RunE: bRevealTaprootDepositWithExtraData, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -2107,24 +2357,26 @@ func bSetSpvMaintainerStatusCommand() *cobra.Command { return c } -func bSetSpvMaintainerStatus(c *cobra.Command, args []string) error { +func bRevealTaprootDepositWithExtraData(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_spvMaintainer, err := chainutil.AddressFromHex(args[0]) - if err != nil { - return fmt.Errorf( - "couldn't parse parameter arg_spvMaintainer, a address, from passed value %v", - args[0], - ) + arg_fundingTx_json := abi.BitcoinTxInfo{} + if err := json.Unmarshal([]byte(args[0]), &arg_fundingTx_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_fundingTx_json to abi.BitcoinTxInfo: %w", err) } - arg_isTrusted, err := strconv.ParseBool(args[1]) + + arg_reveal_json := abi.DepositTaprootDepositRevealInfo{} + if err := json.Unmarshal([]byte(args[1]), &arg_reveal_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_reveal_json to abi.DepositTaprootDepositRevealInfo: %w", err) + } + arg_extraData, err := decode.ParseBytes32(args[2]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg_isTrusted, a bool, from passed value %v", - args[1], + "couldn't parse parameter arg_extraData, a bytes32, from passed value %v", + args[2], ) } @@ -2134,9 +2386,10 @@ func bSetSpvMaintainerStatus(c *cobra.Command, args []string) error { if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { // Do a regular submission. Take payable into account. - transaction, err = contract.SetSpvMaintainerStatus( - arg_spvMaintainer, - arg_isTrusted, + transaction, err = contract.RevealTaprootDepositWithExtraData( + arg_fundingTx_json, + arg_reveal_json, + arg_extraData, ) if err != nil { return err @@ -2145,11 +2398,12 @@ func bSetSpvMaintainerStatus(c *cobra.Command, args []string) error { cmd.PrintOutput(transaction.Hash()) } else { // Do a call. - err = contract.CallSetSpvMaintainerStatus( - arg_spvMaintainer, - arg_isTrusted, - cmd.BlockFlagValue.Int, - ) + err = contract.CallRevealTaprootDepositWithExtraData( + arg_fundingTx_json, + arg_reveal_json, + arg_extraData, + cmd.BlockFlagValue.Int, + ) if err != nil { return err } @@ -2165,12 +2419,12 @@ func bSetSpvMaintainerStatus(c *cobra.Command, args []string) error { return nil } -func bSetVaultStatusCommand() *cobra.Command { +func bSetEcdsaFraudRouterCommand() *cobra.Command { c := &cobra.Command{ - Use: "set-vault-status [arg_vault] [arg_isTrusted]", - Short: "Calls the nonpayable method setVaultStatus on the Bridge contract.", - Args: cmd.ArgCountChecker(2), - RunE: bSetVaultStatus, + Use: "set-ecdsa-fraud-router [arg_ecdsaFraudRouter]", + Short: "Calls the nonpayable method setEcdsaFraudRouter on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetEcdsaFraudRouter, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -2181,24 +2435,82 @@ func bSetVaultStatusCommand() *cobra.Command { return c } -func bSetVaultStatus(c *cobra.Command, args []string) error { +func bSetEcdsaFraudRouter(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_vault, err := chainutil.AddressFromHex(args[0]) + arg_ecdsaFraudRouter, err := chainutil.AddressFromHex(args[0]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg_vault, a address, from passed value %v", + "couldn't parse parameter arg_ecdsaFraudRouter, a address, from passed value %v", args[0], ) } - arg_isTrusted, err := strconv.ParseBool(args[1]) + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetEcdsaFraudRouter( + arg_ecdsaFraudRouter, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetEcdsaFraudRouter( + arg_ecdsaFraudRouter, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func bSetFrostWalletRegistryCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-frost-wallet-registry [arg_frostWalletRegistry]", + Short: "Calls the nonpayable method setFrostWalletRegistry on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetFrostWalletRegistry, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetFrostWalletRegistry(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_frostWalletRegistry, err := chainutil.AddressFromHex(args[0]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg_isTrusted, a bool, from passed value %v", - args[1], + "couldn't parse parameter arg_frostWalletRegistry, a address, from passed value %v", + args[0], ) } @@ -2208,9 +2520,8 @@ func bSetVaultStatus(c *cobra.Command, args []string) error { if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { // Do a regular submission. Take payable into account. - transaction, err = contract.SetVaultStatus( - arg_vault, - arg_isTrusted, + transaction, err = contract.SetFrostWalletRegistry( + arg_frostWalletRegistry, ) if err != nil { return err @@ -2219,9 +2530,8 @@ func bSetVaultStatus(c *cobra.Command, args []string) error { cmd.PrintOutput(transaction.Hash()) } else { // Do a call. - err = contract.CallSetVaultStatus( - arg_vault, - arg_isTrusted, + err = contract.CallSetFrostWalletRegistry( + arg_frostWalletRegistry, cmd.BlockFlagValue.Int, ) if err != nil { @@ -2239,12 +2549,12 @@ func bSetVaultStatus(c *cobra.Command, args []string) error { return nil } -func bSubmitDepositSweepProofCommand() *cobra.Command { +func bSetLifecycleRouterCommand() *cobra.Command { c := &cobra.Command{ - Use: "submit-deposit-sweep-proof [arg_sweepTx_json] [arg_sweepProof_json] [arg_mainUtxo_json] [arg_vault]", - Short: "Calls the nonpayable method submitDepositSweepProof on the Bridge contract.", - Args: cmd.ArgCountChecker(4), - RunE: bSubmitDepositSweepProof, + Use: "set-lifecycle-router [arg_lifecycleRouter]", + Short: "Calls the nonpayable method setLifecycleRouter on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetLifecycleRouter, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -2255,31 +2565,82 @@ func bSubmitDepositSweepProofCommand() *cobra.Command { return c } -func bSubmitDepositSweepProof(c *cobra.Command, args []string) error { +func bSetLifecycleRouter(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_sweepTx_json := abi.BitcoinTxInfo{} - if err := json.Unmarshal([]byte(args[0]), &arg_sweepTx_json); err != nil { - return fmt.Errorf("failed to unmarshal arg_sweepTx_json to abi.BitcoinTxInfo: %w", err) + arg_lifecycleRouter, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_lifecycleRouter, a address, from passed value %v", + args[0], + ) } - arg_sweepProof_json := abi.BitcoinTxProof{} - if err := json.Unmarshal([]byte(args[1]), &arg_sweepProof_json); err != nil { - return fmt.Errorf("failed to unmarshal arg_sweepProof_json to abi.BitcoinTxProof: %w", err) + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetLifecycleRouter( + arg_lifecycleRouter, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetLifecycleRouter( + arg_lifecycleRouter, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) } - arg_mainUtxo_json := abi.BitcoinTxUTXO{} - if err := json.Unmarshal([]byte(args[2]), &arg_mainUtxo_json); err != nil { - return fmt.Errorf("failed to unmarshal arg_mainUtxo_json to abi.BitcoinTxUTXO: %w", err) + return nil +} + +func bSetP2TRFraudRouterCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-p2-t-r-fraud-router [arg_p2trFraudRouter]", + Short: "Calls the nonpayable method setP2TRFraudRouter on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetP2TRFraudRouter, + SilenceUsage: true, + DisableFlagsInUseLine: true, } - arg_vault, err := chainutil.AddressFromHex(args[3]) + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetP2TRFraudRouter(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_p2trFraudRouter, err := chainutil.AddressFromHex(args[0]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg_vault, a address, from passed value %v", - args[3], + "couldn't parse parameter arg_p2trFraudRouter, a address, from passed value %v", + args[0], ) } @@ -2289,11 +2650,8 @@ func bSubmitDepositSweepProof(c *cobra.Command, args []string) error { if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { // Do a regular submission. Take payable into account. - transaction, err = contract.SubmitDepositSweepProof( - arg_sweepTx_json, - arg_sweepProof_json, - arg_mainUtxo_json, - arg_vault, + transaction, err = contract.SetP2TRFraudRouter( + arg_p2trFraudRouter, ) if err != nil { return err @@ -2302,11 +2660,8 @@ func bSubmitDepositSweepProof(c *cobra.Command, args []string) error { cmd.PrintOutput(transaction.Hash()) } else { // Do a call. - err = contract.CallSubmitDepositSweepProof( - arg_sweepTx_json, - arg_sweepProof_json, - arg_mainUtxo_json, - arg_vault, + err = contract.CallSetP2TRFraudRouter( + arg_p2trFraudRouter, cmd.BlockFlagValue.Int, ) if err != nil { @@ -2324,12 +2679,12 @@ func bSubmitDepositSweepProof(c *cobra.Command, args []string) error { return nil } -func bSubmitFraudChallengeCommand() *cobra.Command { +func bSetRebateStakingCommand() *cobra.Command { c := &cobra.Command{ - Use: "submit-fraud-challenge [arg_walletPublicKey] [arg_preimageSha256] [arg_signature_json]", - Short: "Calls the payable method submitFraudChallenge on the Bridge contract.", - Args: cmd.ArgCountChecker(3), - RunE: bSubmitFraudChallenge, + Use: "set-rebate-staking [arg_rebateStaking]", + Short: "Calls the nonpayable method setRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetRebateStaking, SilenceUsage: true, DisableFlagsInUseLine: true, } @@ -2340,30 +2695,310 @@ func bSubmitFraudChallengeCommand() *cobra.Command { return c } -func bSubmitFraudChallenge(c *cobra.Command, args []string) error { +func bSetRebateStaking(c *cobra.Command, args []string) error { contract, err := initializeBridge(c) if err != nil { return err } - arg_walletPublicKey, err := hexutil.Decode(args[0]) + arg_rebateStaking, err := chainutil.AddressFromHex(args[0]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg_walletPublicKey, a bytes, from passed value %v", + "couldn't parse parameter arg_rebateStaking, a address, from passed value %v", args[0], ) } - arg_preimageSha256, err := hexutil.Decode(args[1]) + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetRebateStaking( + arg_rebateStaking, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetRebateStaking( + arg_rebateStaking, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func bSetRedemptionWatchtowerCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-redemption-watchtower [arg_redemptionWatchtower]", + Short: "Calls the nonpayable method setRedemptionWatchtower on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetRedemptionWatchtower, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetRedemptionWatchtower(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_redemptionWatchtower, err := chainutil.AddressFromHex(args[0]) if err != nil { return fmt.Errorf( - "couldn't parse parameter arg_preimageSha256, a bytes, from passed value %v", + "couldn't parse parameter arg_redemptionWatchtower, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetRedemptionWatchtower( + arg_redemptionWatchtower, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetRedemptionWatchtower( + arg_redemptionWatchtower, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func bSetSpvMaintainerStatusCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-spv-maintainer-status [arg_spvMaintainer] [arg_isTrusted]", + Short: "Calls the nonpayable method setSpvMaintainerStatus on the Bridge contract.", + Args: cmd.ArgCountChecker(2), + RunE: bSetSpvMaintainerStatus, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetSpvMaintainerStatus(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_spvMaintainer, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_spvMaintainer, a address, from passed value %v", + args[0], + ) + } + arg_isTrusted, err := strconv.ParseBool(args[1]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_isTrusted, a bool, from passed value %v", + args[1], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetSpvMaintainerStatus( + arg_spvMaintainer, + arg_isTrusted, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetSpvMaintainerStatus( + arg_spvMaintainer, + arg_isTrusted, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func bSetVaultStatusCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-vault-status [arg_vault] [arg_isTrusted]", + Short: "Calls the nonpayable method setVaultStatus on the Bridge contract.", + Args: cmd.ArgCountChecker(2), + RunE: bSetVaultStatus, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetVaultStatus(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_vault, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_vault, a address, from passed value %v", + args[0], + ) + } + arg_isTrusted, err := strconv.ParseBool(args[1]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_isTrusted, a bool, from passed value %v", args[1], ) } - arg_signature_json := abi.BitcoinTxRSVSignature{} - if err := json.Unmarshal([]byte(args[2]), &arg_signature_json); err != nil { - return fmt.Errorf("failed to unmarshal arg_signature_json to abi.BitcoinTxRSVSignature: %w", err) + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetVaultStatus( + arg_vault, + arg_isTrusted, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetVaultStatus( + arg_vault, + arg_isTrusted, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func bSubmitDepositSweepProofCommand() *cobra.Command { + c := &cobra.Command{ + Use: "submit-deposit-sweep-proof [arg_sweepTx_json] [arg_sweepProof_json] [arg_mainUtxo_json] [arg_vault]", + Short: "Calls the nonpayable method submitDepositSweepProof on the Bridge contract.", + Args: cmd.ArgCountChecker(4), + RunE: bSubmitDepositSweepProof, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSubmitDepositSweepProof(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_sweepTx_json := abi.BitcoinTxInfo{} + if err := json.Unmarshal([]byte(args[0]), &arg_sweepTx_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_sweepTx_json to abi.BitcoinTxInfo: %w", err) + } + + arg_sweepProof_json := abi.BitcoinTxProof{} + if err := json.Unmarshal([]byte(args[1]), &arg_sweepProof_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_sweepProof_json to abi.BitcoinTxProof: %w", err) + } + + arg_mainUtxo_json := abi.BitcoinTxUTXO{} + if err := json.Unmarshal([]byte(args[2]), &arg_mainUtxo_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_mainUtxo_json to abi.BitcoinTxUTXO: %w", err) + } + arg_vault, err := chainutil.AddressFromHex(args[3]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_vault, a address, from passed value %v", + args[3], + ) } var ( @@ -2372,10 +3007,11 @@ func bSubmitFraudChallenge(c *cobra.Command, args []string) error { if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { // Do a regular submission. Take payable into account. - transaction, err = contract.SubmitFraudChallenge( - arg_walletPublicKey, - arg_preimageSha256, - arg_signature_json, + transaction, err = contract.SubmitDepositSweepProof( + arg_sweepTx_json, + arg_sweepProof_json, + arg_mainUtxo_json, + arg_vault, ) if err != nil { return err @@ -2384,10 +3020,11 @@ func bSubmitFraudChallenge(c *cobra.Command, args []string) error { cmd.PrintOutput(transaction.Hash()) } else { // Do a call. - err = contract.CallSubmitFraudChallenge( - arg_walletPublicKey, - arg_preimageSha256, - arg_signature_json, + err = contract.CallSubmitDepositSweepProof( + arg_sweepTx_json, + arg_sweepProof_json, + arg_mainUtxo_json, + arg_vault, cmd.BlockFlagValue.Int, ) if err != nil { diff --git a/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go b/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go index ae73c92607..58d179f812 100644 --- a/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go @@ -105,20 +105,20 @@ func NewBridge( // ----- Non-const Methods ------ // Transaction submission. -func (b *Bridge) DefeatFraudChallenge( - arg_walletPublicKey []byte, - arg_preimage []byte, - arg_witness bool, +func (b *Bridge) EcdsaWalletHeartbeatFailedCallback( + arg0 [32]byte, + arg_publicKeyX [32]byte, + arg_publicKeyY [32]byte, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction defeatFraudChallenge", + "submitting transaction ecdsaWalletHeartbeatFailedCallback", " params: ", fmt.Sprint( - arg_walletPublicKey, - arg_preimage, - arg_witness, + arg0, + arg_publicKeyX, + arg_publicKeyY, ), ) @@ -144,26 +144,26 @@ func (b *Bridge) DefeatFraudChallenge( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.DefeatFraudChallenge( + transaction, err := b.contract.EcdsaWalletHeartbeatFailedCallback( transactorOptions, - arg_walletPublicKey, - arg_preimage, - arg_witness, + arg0, + arg_publicKeyX, + arg_publicKeyY, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "defeatFraudChallenge", - arg_walletPublicKey, - arg_preimage, - arg_witness, + "ecdsaWalletHeartbeatFailedCallback", + arg0, + arg_publicKeyX, + arg_publicKeyY, ) } bLogger.Infof( - "submitted transaction defeatFraudChallenge with id: [%s] and nonce [%v]", + "submitted transaction ecdsaWalletHeartbeatFailedCallback with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -182,26 +182,26 @@ func (b *Bridge) DefeatFraudChallenge( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.DefeatFraudChallenge( + transaction, err := b.contract.EcdsaWalletHeartbeatFailedCallback( newTransactorOptions, - arg_walletPublicKey, - arg_preimage, - arg_witness, + arg0, + arg_publicKeyX, + arg_publicKeyY, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "defeatFraudChallenge", - arg_walletPublicKey, - arg_preimage, - arg_witness, + "ecdsaWalletHeartbeatFailedCallback", + arg0, + arg_publicKeyX, + arg_publicKeyY, ) } bLogger.Infof( - "submitted transaction defeatFraudChallenge with id: [%s] and nonce [%v]", + "submitted transaction ecdsaWalletHeartbeatFailedCallback with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -216,10 +216,10 @@ func (b *Bridge) DefeatFraudChallenge( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallDefeatFraudChallenge( - arg_walletPublicKey []byte, - arg_preimage []byte, - arg_witness bool, +func (b *Bridge) CallEcdsaWalletHeartbeatFailedCallback( + arg0 [32]byte, + arg_publicKeyX [32]byte, + arg_publicKeyY [32]byte, blockNumber *big.Int, ) error { var result interface{} = nil @@ -231,50 +231,48 @@ func (b *Bridge) CallDefeatFraudChallenge( b.caller, b.errorResolver, b.contractAddress, - "defeatFraudChallenge", + "ecdsaWalletHeartbeatFailedCallback", &result, - arg_walletPublicKey, - arg_preimage, - arg_witness, + arg0, + arg_publicKeyX, + arg_publicKeyY, ) return err } -func (b *Bridge) DefeatFraudChallengeGasEstimate( - arg_walletPublicKey []byte, - arg_preimage []byte, - arg_witness bool, +func (b *Bridge) EcdsaWalletHeartbeatFailedCallbackGasEstimate( + arg0 [32]byte, + arg_publicKeyX [32]byte, + arg_publicKeyY [32]byte, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "defeatFraudChallenge", + "ecdsaWalletHeartbeatFailedCallback", b.contractABI, b.transactor, - arg_walletPublicKey, - arg_preimage, - arg_witness, + arg0, + arg_publicKeyX, + arg_publicKeyY, ) return result, err } // Transaction submission. -func (b *Bridge) DefeatFraudChallengeWithHeartbeat( - arg_walletPublicKey []byte, - arg_heartbeatMessage []byte, +func (b *Bridge) FrostWalletCreatedCallback( + arg_xOnlyOutputKey [32]byte, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction defeatFraudChallengeWithHeartbeat", + "submitting transaction frostWalletCreatedCallback", " params: ", fmt.Sprint( - arg_walletPublicKey, - arg_heartbeatMessage, + arg_xOnlyOutputKey, ), ) @@ -300,24 +298,22 @@ func (b *Bridge) DefeatFraudChallengeWithHeartbeat( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.DefeatFraudChallengeWithHeartbeat( + transaction, err := b.contract.FrostWalletCreatedCallback( transactorOptions, - arg_walletPublicKey, - arg_heartbeatMessage, + arg_xOnlyOutputKey, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "defeatFraudChallengeWithHeartbeat", - arg_walletPublicKey, - arg_heartbeatMessage, + "frostWalletCreatedCallback", + arg_xOnlyOutputKey, ) } bLogger.Infof( - "submitted transaction defeatFraudChallengeWithHeartbeat with id: [%s] and nonce [%v]", + "submitted transaction frostWalletCreatedCallback with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -336,24 +332,22 @@ func (b *Bridge) DefeatFraudChallengeWithHeartbeat( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.DefeatFraudChallengeWithHeartbeat( + transaction, err := b.contract.FrostWalletCreatedCallback( newTransactorOptions, - arg_walletPublicKey, - arg_heartbeatMessage, + arg_xOnlyOutputKey, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "defeatFraudChallengeWithHeartbeat", - arg_walletPublicKey, - arg_heartbeatMessage, + "frostWalletCreatedCallback", + arg_xOnlyOutputKey, ) } bLogger.Infof( - "submitted transaction defeatFraudChallengeWithHeartbeat with id: [%s] and nonce [%v]", + "submitted transaction frostWalletCreatedCallback with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -368,9 +362,8 @@ func (b *Bridge) DefeatFraudChallengeWithHeartbeat( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallDefeatFraudChallengeWithHeartbeat( - arg_walletPublicKey []byte, - arg_heartbeatMessage []byte, +func (b *Bridge) CallFrostWalletCreatedCallback( + arg_xOnlyOutputKey [32]byte, blockNumber *big.Int, ) error { var result interface{} = nil @@ -382,49 +375,52 @@ func (b *Bridge) CallDefeatFraudChallengeWithHeartbeat( b.caller, b.errorResolver, b.contractAddress, - "defeatFraudChallengeWithHeartbeat", + "frostWalletCreatedCallback", &result, - arg_walletPublicKey, - arg_heartbeatMessage, + arg_xOnlyOutputKey, ) return err } -func (b *Bridge) DefeatFraudChallengeWithHeartbeatGasEstimate( - arg_walletPublicKey []byte, - arg_heartbeatMessage []byte, +func (b *Bridge) FrostWalletCreatedCallbackGasEstimate( + arg_xOnlyOutputKey [32]byte, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "defeatFraudChallengeWithHeartbeat", + "frostWalletCreatedCallback", b.contractABI, b.transactor, - arg_walletPublicKey, - arg_heartbeatMessage, + arg_xOnlyOutputKey, ) return result, err } // Transaction submission. -func (b *Bridge) EcdsaWalletCreatedCallback( - arg_ecdsaWalletID [32]byte, - arg_publicKeyX [32]byte, - arg_publicKeyY [32]byte, +func (b *Bridge) Initialize( + arg__bank common.Address, + arg__relay common.Address, + arg__treasury common.Address, + arg__ecdsaWalletRegistry common.Address, + arg__reimbursementPool common.Address, + arg__txProofDifficultyFactor *big.Int, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction ecdsaWalletCreatedCallback", + "submitting transaction initialize", " params: ", fmt.Sprint( - arg_ecdsaWalletID, - arg_publicKeyX, - arg_publicKeyY, + arg__bank, + arg__relay, + arg__treasury, + arg__ecdsaWalletRegistry, + arg__reimbursementPool, + arg__txProofDifficultyFactor, ), ) @@ -450,26 +446,32 @@ func (b *Bridge) EcdsaWalletCreatedCallback( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.EcdsaWalletCreatedCallback( + transaction, err := b.contract.Initialize( transactorOptions, - arg_ecdsaWalletID, - arg_publicKeyX, - arg_publicKeyY, + arg__bank, + arg__relay, + arg__treasury, + arg__ecdsaWalletRegistry, + arg__reimbursementPool, + arg__txProofDifficultyFactor, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "ecdsaWalletCreatedCallback", - arg_ecdsaWalletID, - arg_publicKeyX, - arg_publicKeyY, + "initialize", + arg__bank, + arg__relay, + arg__treasury, + arg__ecdsaWalletRegistry, + arg__reimbursementPool, + arg__txProofDifficultyFactor, ) } bLogger.Infof( - "submitted transaction ecdsaWalletCreatedCallback with id: [%s] and nonce [%v]", + "submitted transaction initialize with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -488,26 +490,32 @@ func (b *Bridge) EcdsaWalletCreatedCallback( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.EcdsaWalletCreatedCallback( + transaction, err := b.contract.Initialize( newTransactorOptions, - arg_ecdsaWalletID, - arg_publicKeyX, - arg_publicKeyY, + arg__bank, + arg__relay, + arg__treasury, + arg__ecdsaWalletRegistry, + arg__reimbursementPool, + arg__txProofDifficultyFactor, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "ecdsaWalletCreatedCallback", - arg_ecdsaWalletID, - arg_publicKeyX, - arg_publicKeyY, + "initialize", + arg__bank, + arg__relay, + arg__treasury, + arg__ecdsaWalletRegistry, + arg__reimbursementPool, + arg__txProofDifficultyFactor, ) } bLogger.Infof( - "submitted transaction ecdsaWalletCreatedCallback with id: [%s] and nonce [%v]", + "submitted transaction initialize with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -522,10 +530,13 @@ func (b *Bridge) EcdsaWalletCreatedCallback( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallEcdsaWalletCreatedCallback( - arg_ecdsaWalletID [32]byte, - arg_publicKeyX [32]byte, - arg_publicKeyY [32]byte, +func (b *Bridge) CallInitialize( + arg__bank common.Address, + arg__relay common.Address, + arg__treasury common.Address, + arg__ecdsaWalletRegistry common.Address, + arg__reimbursementPool common.Address, + arg__txProofDifficultyFactor *big.Int, blockNumber *big.Int, ) error { var result interface{} = nil @@ -537,53 +548,53 @@ func (b *Bridge) CallEcdsaWalletCreatedCallback( b.caller, b.errorResolver, b.contractAddress, - "ecdsaWalletCreatedCallback", + "initialize", &result, - arg_ecdsaWalletID, - arg_publicKeyX, - arg_publicKeyY, + arg__bank, + arg__relay, + arg__treasury, + arg__ecdsaWalletRegistry, + arg__reimbursementPool, + arg__txProofDifficultyFactor, ) return err } -func (b *Bridge) EcdsaWalletCreatedCallbackGasEstimate( - arg_ecdsaWalletID [32]byte, - arg_publicKeyX [32]byte, - arg_publicKeyY [32]byte, +func (b *Bridge) InitializeGasEstimate( + arg__bank common.Address, + arg__relay common.Address, + arg__treasury common.Address, + arg__ecdsaWalletRegistry common.Address, + arg__reimbursementPool common.Address, + arg__txProofDifficultyFactor *big.Int, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "ecdsaWalletCreatedCallback", + "initialize", b.contractABI, b.transactor, - arg_ecdsaWalletID, - arg_publicKeyX, - arg_publicKeyY, + arg__bank, + arg__relay, + arg__treasury, + arg__ecdsaWalletRegistry, + arg__reimbursementPool, + arg__txProofDifficultyFactor, ) return result, err } // Transaction submission. -func (b *Bridge) EcdsaWalletHeartbeatFailedCallback( - arg0 [32]byte, - arg_publicKeyX [32]byte, - arg_publicKeyY [32]byte, +func (b *Bridge) InitializeV2FixVaultZeroDeposit( transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction ecdsaWalletHeartbeatFailedCallback", - " params: ", - fmt.Sprint( - arg0, - arg_publicKeyX, - arg_publicKeyY, - ), + "submitting transaction initializeV2FixVaultZeroDeposit", ) b.transactionMutex.Lock() @@ -608,26 +619,20 @@ func (b *Bridge) EcdsaWalletHeartbeatFailedCallback( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.EcdsaWalletHeartbeatFailedCallback( + transaction, err := b.contract.InitializeV2FixVaultZeroDeposit( transactorOptions, - arg0, - arg_publicKeyX, - arg_publicKeyY, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "ecdsaWalletHeartbeatFailedCallback", - arg0, - arg_publicKeyX, - arg_publicKeyY, + "initializeV2FixVaultZeroDeposit", ) } bLogger.Infof( - "submitted transaction ecdsaWalletHeartbeatFailedCallback with id: [%s] and nonce [%v]", + "submitted transaction initializeV2FixVaultZeroDeposit with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -646,26 +651,20 @@ func (b *Bridge) EcdsaWalletHeartbeatFailedCallback( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.EcdsaWalletHeartbeatFailedCallback( + transaction, err := b.contract.InitializeV2FixVaultZeroDeposit( newTransactorOptions, - arg0, - arg_publicKeyX, - arg_publicKeyY, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "ecdsaWalletHeartbeatFailedCallback", - arg0, - arg_publicKeyX, - arg_publicKeyY, + "initializeV2FixVaultZeroDeposit", ) } bLogger.Infof( - "submitted transaction ecdsaWalletHeartbeatFailedCallback with id: [%s] and nonce [%v]", + "submitted transaction initializeV2FixVaultZeroDeposit with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -680,10 +679,7 @@ func (b *Bridge) EcdsaWalletHeartbeatFailedCallback( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallEcdsaWalletHeartbeatFailedCallback( - arg0 [32]byte, - arg_publicKeyX [32]byte, - arg_publicKeyY [32]byte, +func (b *Bridge) CallInitializeV2FixVaultZeroDeposit( blockNumber *big.Int, ) error { var result interface{} = nil @@ -695,58 +691,40 @@ func (b *Bridge) CallEcdsaWalletHeartbeatFailedCallback( b.caller, b.errorResolver, b.contractAddress, - "ecdsaWalletHeartbeatFailedCallback", + "initializeV2FixVaultZeroDeposit", &result, - arg0, - arg_publicKeyX, - arg_publicKeyY, ) return err } -func (b *Bridge) EcdsaWalletHeartbeatFailedCallbackGasEstimate( - arg0 [32]byte, - arg_publicKeyX [32]byte, - arg_publicKeyY [32]byte, -) (uint64, error) { +func (b *Bridge) InitializeV2FixVaultZeroDepositGasEstimate() (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "ecdsaWalletHeartbeatFailedCallback", + "initializeV2FixVaultZeroDeposit", b.contractABI, b.transactor, - arg0, - arg_publicKeyX, - arg_publicKeyY, ) return result, err } // Transaction submission. -func (b *Bridge) Initialize( - arg__bank common.Address, - arg__relay common.Address, - arg__treasury common.Address, - arg__ecdsaWalletRegistry common.Address, - arg__reimbursementPool common.Address, - arg__txProofDifficultyFactor *big.Int, +func (b *Bridge) MigrateLegacyFraudChallenges( + arg_routerKind uint8, + arg_challengeKeys []*big.Int, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction initialize", + "submitting transaction migrateLegacyFraudChallenges", " params: ", fmt.Sprint( - arg__bank, - arg__relay, - arg__treasury, - arg__ecdsaWalletRegistry, - arg__reimbursementPool, - arg__txProofDifficultyFactor, + arg_routerKind, + arg_challengeKeys, ), ) @@ -772,32 +750,24 @@ func (b *Bridge) Initialize( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.Initialize( + transaction, err := b.contract.MigrateLegacyFraudChallenges( transactorOptions, - arg__bank, - arg__relay, - arg__treasury, - arg__ecdsaWalletRegistry, - arg__reimbursementPool, - arg__txProofDifficultyFactor, + arg_routerKind, + arg_challengeKeys, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "initialize", - arg__bank, - arg__relay, - arg__treasury, - arg__ecdsaWalletRegistry, - arg__reimbursementPool, - arg__txProofDifficultyFactor, + "migrateLegacyFraudChallenges", + arg_routerKind, + arg_challengeKeys, ) } bLogger.Infof( - "submitted transaction initialize with id: [%s] and nonce [%v]", + "submitted transaction migrateLegacyFraudChallenges with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -816,32 +786,24 @@ func (b *Bridge) Initialize( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.Initialize( + transaction, err := b.contract.MigrateLegacyFraudChallenges( newTransactorOptions, - arg__bank, - arg__relay, - arg__treasury, - arg__ecdsaWalletRegistry, - arg__reimbursementPool, - arg__txProofDifficultyFactor, + arg_routerKind, + arg_challengeKeys, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "initialize", - arg__bank, - arg__relay, - arg__treasury, - arg__ecdsaWalletRegistry, - arg__reimbursementPool, - arg__txProofDifficultyFactor, + "migrateLegacyFraudChallenges", + arg_routerKind, + arg_challengeKeys, ) } bLogger.Infof( - "submitted transaction initialize with id: [%s] and nonce [%v]", + "submitted transaction migrateLegacyFraudChallenges with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -856,13 +818,9 @@ func (b *Bridge) Initialize( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallInitialize( - arg__bank common.Address, - arg__relay common.Address, - arg__treasury common.Address, - arg__ecdsaWalletRegistry common.Address, - arg__reimbursementPool common.Address, - arg__txProofDifficultyFactor *big.Int, +func (b *Bridge) CallMigrateLegacyFraudChallenges( + arg_routerKind uint8, + arg_challengeKeys []*big.Int, blockNumber *big.Int, ) error { var result interface{} = nil @@ -874,214 +832,44 @@ func (b *Bridge) CallInitialize( b.caller, b.errorResolver, b.contractAddress, - "initialize", + "migrateLegacyFraudChallenges", &result, - arg__bank, - arg__relay, - arg__treasury, - arg__ecdsaWalletRegistry, - arg__reimbursementPool, - arg__txProofDifficultyFactor, + arg_routerKind, + arg_challengeKeys, ) return err } -func (b *Bridge) InitializeGasEstimate( - arg__bank common.Address, - arg__relay common.Address, - arg__treasury common.Address, - arg__ecdsaWalletRegistry common.Address, - arg__reimbursementPool common.Address, - arg__txProofDifficultyFactor *big.Int, +func (b *Bridge) MigrateLegacyFraudChallengesGasEstimate( + arg_routerKind uint8, + arg_challengeKeys []*big.Int, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "initialize", + "migrateLegacyFraudChallenges", b.contractABI, b.transactor, - arg__bank, - arg__relay, - arg__treasury, - arg__ecdsaWalletRegistry, - arg__reimbursementPool, - arg__txProofDifficultyFactor, + arg_routerKind, + arg_challengeKeys, ) return result, err } // Transaction submission. -func (b *Bridge) NotifyFraudChallengeDefeatTimeout( - arg_walletPublicKey []byte, +func (b *Bridge) NotifyMovedFundsSweepTimeout( + arg_movingFundsTxHash [32]byte, + arg_movingFundsTxOutputIndex uint32, arg_walletMembersIDs []uint32, - arg_preimageSha256 []byte, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction notifyFraudChallengeDefeatTimeout", - " params: ", - fmt.Sprint( - arg_walletPublicKey, - arg_walletMembersIDs, - arg_preimageSha256, - ), - ) - - b.transactionMutex.Lock() - defer b.transactionMutex.Unlock() - - // create a copy - transactorOptions := new(bind.TransactOpts) - *transactorOptions = *b.transactorOptions - - if len(transactionOptions) > 1 { - return nil, fmt.Errorf( - "could not process multiple transaction options sets", - ) - } else if len(transactionOptions) > 0 { - transactionOptions[0].Apply(transactorOptions) - } - - nonce, err := b.nonceManager.CurrentNonce() - if err != nil { - return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) - } - - transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - - transaction, err := b.contract.NotifyFraudChallengeDefeatTimeout( - transactorOptions, - arg_walletPublicKey, - arg_walletMembersIDs, - arg_preimageSha256, - ) - if err != nil { - return transaction, b.errorResolver.ResolveError( - err, - b.transactorOptions.From, - nil, - "notifyFraudChallengeDefeatTimeout", - arg_walletPublicKey, - arg_walletMembersIDs, - arg_preimageSha256, - ) - } - - bLogger.Infof( - "submitted transaction notifyFraudChallengeDefeatTimeout with id: [%s] and nonce [%v]", - transaction.Hash(), - transaction.Nonce(), - ) - - go b.miningWaiter.ForceMining( - transaction, - transactorOptions, - func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { - // If original transactor options has a non-zero gas limit, that - // means the client code set it on their own. In that case, we - // should rewrite the gas limit from the original transaction - // for each resubmission. If the gas limit is not set by the client - // code, let the the submitter re-estimate the gas limit on each - // resubmission. - if transactorOptions.GasLimit != 0 { - newTransactorOptions.GasLimit = transactorOptions.GasLimit - } - - transaction, err := b.contract.NotifyFraudChallengeDefeatTimeout( - newTransactorOptions, - arg_walletPublicKey, - arg_walletMembersIDs, - arg_preimageSha256, - ) - if err != nil { - return nil, b.errorResolver.ResolveError( - err, - b.transactorOptions.From, - nil, - "notifyFraudChallengeDefeatTimeout", - arg_walletPublicKey, - arg_walletMembersIDs, - arg_preimageSha256, - ) - } - - bLogger.Infof( - "submitted transaction notifyFraudChallengeDefeatTimeout with id: [%s] and nonce [%v]", - transaction.Hash(), - transaction.Nonce(), - ) - - return transaction, nil - }, - ) - - b.nonceManager.IncrementNonce() - - return transaction, err -} - -// Non-mutating call, not a transaction submission. -func (b *Bridge) CallNotifyFraudChallengeDefeatTimeout( - arg_walletPublicKey []byte, - arg_walletMembersIDs []uint32, - arg_preimageSha256 []byte, - blockNumber *big.Int, -) error { - var result interface{} = nil - - err := chainutil.CallAtBlock( - b.transactorOptions.From, - blockNumber, nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "notifyFraudChallengeDefeatTimeout", - &result, - arg_walletPublicKey, - arg_walletMembersIDs, - arg_preimageSha256, - ) - - return err -} - -func (b *Bridge) NotifyFraudChallengeDefeatTimeoutGasEstimate( - arg_walletPublicKey []byte, - arg_walletMembersIDs []uint32, - arg_preimageSha256 []byte, -) (uint64, error) { - var result uint64 - - result, err := chainutil.EstimateGas( - b.callerOptions.From, - b.contractAddress, - "notifyFraudChallengeDefeatTimeout", - b.contractABI, - b.transactor, - arg_walletPublicKey, - arg_walletMembersIDs, - arg_preimageSha256, - ) - - return result, err -} - -// Transaction submission. -func (b *Bridge) NotifyMovedFundsSweepTimeout( - arg_movingFundsTxHash [32]byte, - arg_movingFundsTxOutputIndex uint32, - arg_walletMembersIDs []uint32, - - transactionOptions ...chainutil.TransactionOptions, -) (*types.Transaction, error) { - bLogger.Debug( - "submitting transaction notifyMovedFundsSweepTimeout", + "submitting transaction notifyMovedFundsSweepTimeout", " params: ", fmt.Sprint( arg_movingFundsTxHash, @@ -2721,19 +2509,12 @@ func (b *Bridge) ResetMovingFundsTimeoutGasEstimate( } // Transaction submission. -func (b *Bridge) RevealDeposit( - arg_fundingTx abi.BitcoinTxInfo, - arg_reveal abi.DepositDepositRevealInfo, +func (b *Bridge) RetireEcdsa( transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction revealDeposit", - " params: ", - fmt.Sprint( - arg_fundingTx, - arg_reveal, - ), + "submitting transaction retireEcdsa", ) b.transactionMutex.Lock() @@ -2758,24 +2539,20 @@ func (b *Bridge) RevealDeposit( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.RevealDeposit( + transaction, err := b.contract.RetireEcdsa( transactorOptions, - arg_fundingTx, - arg_reveal, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "revealDeposit", - arg_fundingTx, - arg_reveal, + "retireEcdsa", ) } bLogger.Infof( - "submitted transaction revealDeposit with id: [%s] and nonce [%v]", + "submitted transaction retireEcdsa with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -2794,24 +2571,20 @@ func (b *Bridge) RevealDeposit( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.RevealDeposit( + transaction, err := b.contract.RetireEcdsa( newTransactorOptions, - arg_fundingTx, - arg_reveal, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "revealDeposit", - arg_fundingTx, - arg_reveal, + "retireEcdsa", ) } bLogger.Infof( - "submitted transaction revealDeposit with id: [%s] and nonce [%v]", + "submitted transaction retireEcdsa with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -2826,9 +2599,7 @@ func (b *Bridge) RevealDeposit( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallRevealDeposit( - arg_fundingTx abi.BitcoinTxInfo, - arg_reveal abi.DepositDepositRevealInfo, +func (b *Bridge) CallRetireEcdsa( blockNumber *big.Int, ) error { var result interface{} = nil @@ -2840,49 +2611,40 @@ func (b *Bridge) CallRevealDeposit( b.caller, b.errorResolver, b.contractAddress, - "revealDeposit", + "retireEcdsa", &result, - arg_fundingTx, - arg_reveal, ) return err } -func (b *Bridge) RevealDepositGasEstimate( - arg_fundingTx abi.BitcoinTxInfo, - arg_reveal abi.DepositDepositRevealInfo, -) (uint64, error) { +func (b *Bridge) RetireEcdsaGasEstimate() (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "revealDeposit", + "retireEcdsa", b.contractABI, b.transactor, - arg_fundingTx, - arg_reveal, ) return result, err } // Transaction submission. -func (b *Bridge) RevealDepositWithExtraData( +func (b *Bridge) RevealDeposit( arg_fundingTx abi.BitcoinTxInfo, arg_reveal abi.DepositDepositRevealInfo, - arg_extraData [32]byte, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction revealDepositWithExtraData", + "submitting transaction revealDeposit", " params: ", fmt.Sprint( arg_fundingTx, arg_reveal, - arg_extraData, ), ) @@ -2908,26 +2670,24 @@ func (b *Bridge) RevealDepositWithExtraData( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.RevealDepositWithExtraData( + transaction, err := b.contract.RevealDeposit( transactorOptions, arg_fundingTx, arg_reveal, - arg_extraData, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "revealDepositWithExtraData", + "revealDeposit", arg_fundingTx, arg_reveal, - arg_extraData, ) } bLogger.Infof( - "submitted transaction revealDepositWithExtraData with id: [%s] and nonce [%v]", + "submitted transaction revealDeposit with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -2946,26 +2706,24 @@ func (b *Bridge) RevealDepositWithExtraData( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.RevealDepositWithExtraData( + transaction, err := b.contract.RevealDeposit( newTransactorOptions, arg_fundingTx, arg_reveal, - arg_extraData, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "revealDepositWithExtraData", + "revealDeposit", arg_fundingTx, arg_reveal, - arg_extraData, ) } bLogger.Infof( - "submitted transaction revealDepositWithExtraData with id: [%s] and nonce [%v]", + "submitted transaction revealDeposit with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -2980,10 +2738,9 @@ func (b *Bridge) RevealDepositWithExtraData( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallRevealDepositWithExtraData( +func (b *Bridge) CallRevealDeposit( arg_fundingTx abi.BitcoinTxInfo, arg_reveal abi.DepositDepositRevealInfo, - arg_extraData [32]byte, blockNumber *big.Int, ) error { var result interface{} = nil @@ -2995,48 +2752,49 @@ func (b *Bridge) CallRevealDepositWithExtraData( b.caller, b.errorResolver, b.contractAddress, - "revealDepositWithExtraData", + "revealDeposit", &result, arg_fundingTx, arg_reveal, - arg_extraData, ) return err } -func (b *Bridge) RevealDepositWithExtraDataGasEstimate( +func (b *Bridge) RevealDepositGasEstimate( arg_fundingTx abi.BitcoinTxInfo, arg_reveal abi.DepositDepositRevealInfo, - arg_extraData [32]byte, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "revealDepositWithExtraData", + "revealDeposit", b.contractABI, b.transactor, arg_fundingTx, arg_reveal, - arg_extraData, ) return result, err } // Transaction submission. -func (b *Bridge) SetRedemptionWatchtower( - arg_redemptionWatchtower common.Address, +func (b *Bridge) RevealDepositWithExtraData( + arg_fundingTx abi.BitcoinTxInfo, + arg_reveal abi.DepositDepositRevealInfo, + arg_extraData [32]byte, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction setRedemptionWatchtower", + "submitting transaction revealDepositWithExtraData", " params: ", fmt.Sprint( - arg_redemptionWatchtower, + arg_fundingTx, + arg_reveal, + arg_extraData, ), ) @@ -3062,22 +2820,26 @@ func (b *Bridge) SetRedemptionWatchtower( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SetRedemptionWatchtower( + transaction, err := b.contract.RevealDepositWithExtraData( transactorOptions, - arg_redemptionWatchtower, + arg_fundingTx, + arg_reveal, + arg_extraData, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setRedemptionWatchtower", - arg_redemptionWatchtower, + "revealDepositWithExtraData", + arg_fundingTx, + arg_reveal, + arg_extraData, ) } bLogger.Infof( - "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + "submitted transaction revealDepositWithExtraData with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3096,22 +2858,26 @@ func (b *Bridge) SetRedemptionWatchtower( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SetRedemptionWatchtower( + transaction, err := b.contract.RevealDepositWithExtraData( newTransactorOptions, - arg_redemptionWatchtower, + arg_fundingTx, + arg_reveal, + arg_extraData, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setRedemptionWatchtower", - arg_redemptionWatchtower, + "revealDepositWithExtraData", + arg_fundingTx, + arg_reveal, + arg_extraData, ) } bLogger.Infof( - "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + "submitted transaction revealDepositWithExtraData with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3126,8 +2892,10 @@ func (b *Bridge) SetRedemptionWatchtower( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSetRedemptionWatchtower( - arg_redemptionWatchtower common.Address, +func (b *Bridge) CallRevealDepositWithExtraData( + arg_fundingTx abi.BitcoinTxInfo, + arg_reveal abi.DepositDepositRevealInfo, + arg_extraData [32]byte, blockNumber *big.Int, ) error { var result interface{} = nil @@ -3139,44 +2907,50 @@ func (b *Bridge) CallSetRedemptionWatchtower( b.caller, b.errorResolver, b.contractAddress, - "setRedemptionWatchtower", + "revealDepositWithExtraData", &result, - arg_redemptionWatchtower, + arg_fundingTx, + arg_reveal, + arg_extraData, ) return err } -func (b *Bridge) SetRedemptionWatchtowerGasEstimate( - arg_redemptionWatchtower common.Address, -) (uint64, error) { - var result uint64 +func (b *Bridge) RevealDepositWithExtraDataGasEstimate( + arg_fundingTx abi.BitcoinTxInfo, + arg_reveal abi.DepositDepositRevealInfo, + arg_extraData [32]byte, +) (uint64, error) { + var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "setRedemptionWatchtower", + "revealDepositWithExtraData", b.contractABI, b.transactor, - arg_redemptionWatchtower, + arg_fundingTx, + arg_reveal, + arg_extraData, ) return result, err } // Transaction submission. -func (b *Bridge) SetSpvMaintainerStatus( - arg_spvMaintainer common.Address, - arg_isTrusted bool, +func (b *Bridge) RevealTaprootDeposit( + arg_fundingTx abi.BitcoinTxInfo, + arg_reveal abi.DepositTaprootDepositRevealInfo, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction setSpvMaintainerStatus", + "submitting transaction revealTaprootDeposit", " params: ", fmt.Sprint( - arg_spvMaintainer, - arg_isTrusted, + arg_fundingTx, + arg_reveal, ), ) @@ -3202,24 +2976,24 @@ func (b *Bridge) SetSpvMaintainerStatus( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SetSpvMaintainerStatus( + transaction, err := b.contract.RevealTaprootDeposit( transactorOptions, - arg_spvMaintainer, - arg_isTrusted, + arg_fundingTx, + arg_reveal, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setSpvMaintainerStatus", - arg_spvMaintainer, - arg_isTrusted, + "revealTaprootDeposit", + arg_fundingTx, + arg_reveal, ) } bLogger.Infof( - "submitted transaction setSpvMaintainerStatus with id: [%s] and nonce [%v]", + "submitted transaction revealTaprootDeposit with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3238,24 +3012,24 @@ func (b *Bridge) SetSpvMaintainerStatus( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SetSpvMaintainerStatus( + transaction, err := b.contract.RevealTaprootDeposit( newTransactorOptions, - arg_spvMaintainer, - arg_isTrusted, + arg_fundingTx, + arg_reveal, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setSpvMaintainerStatus", - arg_spvMaintainer, - arg_isTrusted, + "revealTaprootDeposit", + arg_fundingTx, + arg_reveal, ) } bLogger.Infof( - "submitted transaction setSpvMaintainerStatus with id: [%s] and nonce [%v]", + "submitted transaction revealTaprootDeposit with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3270,9 +3044,9 @@ func (b *Bridge) SetSpvMaintainerStatus( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSetSpvMaintainerStatus( - arg_spvMaintainer common.Address, - arg_isTrusted bool, +func (b *Bridge) CallRevealTaprootDeposit( + arg_fundingTx abi.BitcoinTxInfo, + arg_reveal abi.DepositTaprootDepositRevealInfo, blockNumber *big.Int, ) error { var result interface{} = nil @@ -3284,47 +3058,49 @@ func (b *Bridge) CallSetSpvMaintainerStatus( b.caller, b.errorResolver, b.contractAddress, - "setSpvMaintainerStatus", + "revealTaprootDeposit", &result, - arg_spvMaintainer, - arg_isTrusted, + arg_fundingTx, + arg_reveal, ) return err } -func (b *Bridge) SetSpvMaintainerStatusGasEstimate( - arg_spvMaintainer common.Address, - arg_isTrusted bool, +func (b *Bridge) RevealTaprootDepositGasEstimate( + arg_fundingTx abi.BitcoinTxInfo, + arg_reveal abi.DepositTaprootDepositRevealInfo, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "setSpvMaintainerStatus", + "revealTaprootDeposit", b.contractABI, b.transactor, - arg_spvMaintainer, - arg_isTrusted, + arg_fundingTx, + arg_reveal, ) return result, err } // Transaction submission. -func (b *Bridge) SetVaultStatus( - arg_vault common.Address, - arg_isTrusted bool, +func (b *Bridge) RevealTaprootDepositWithExtraData( + arg_fundingTx abi.BitcoinTxInfo, + arg_reveal abi.DepositTaprootDepositRevealInfo, + arg_extraData [32]byte, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction setVaultStatus", + "submitting transaction revealTaprootDepositWithExtraData", " params: ", fmt.Sprint( - arg_vault, - arg_isTrusted, + arg_fundingTx, + arg_reveal, + arg_extraData, ), ) @@ -3350,24 +3126,26 @@ func (b *Bridge) SetVaultStatus( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SetVaultStatus( + transaction, err := b.contract.RevealTaprootDepositWithExtraData( transactorOptions, - arg_vault, - arg_isTrusted, + arg_fundingTx, + arg_reveal, + arg_extraData, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setVaultStatus", - arg_vault, - arg_isTrusted, + "revealTaprootDepositWithExtraData", + arg_fundingTx, + arg_reveal, + arg_extraData, ) } bLogger.Infof( - "submitted transaction setVaultStatus with id: [%s] and nonce [%v]", + "submitted transaction revealTaprootDepositWithExtraData with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3386,24 +3164,26 @@ func (b *Bridge) SetVaultStatus( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SetVaultStatus( + transaction, err := b.contract.RevealTaprootDepositWithExtraData( newTransactorOptions, - arg_vault, - arg_isTrusted, + arg_fundingTx, + arg_reveal, + arg_extraData, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setVaultStatus", - arg_vault, - arg_isTrusted, + "revealTaprootDepositWithExtraData", + arg_fundingTx, + arg_reveal, + arg_extraData, ) } bLogger.Infof( - "submitted transaction setVaultStatus with id: [%s] and nonce [%v]", + "submitted transaction revealTaprootDepositWithExtraData with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3418,9 +3198,10 @@ func (b *Bridge) SetVaultStatus( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSetVaultStatus( - arg_vault common.Address, - arg_isTrusted bool, +func (b *Bridge) CallRevealTaprootDepositWithExtraData( + arg_fundingTx abi.BitcoinTxInfo, + arg_reveal abi.DepositTaprootDepositRevealInfo, + arg_extraData [32]byte, blockNumber *big.Int, ) error { var result interface{} = nil @@ -3432,51 +3213,48 @@ func (b *Bridge) CallSetVaultStatus( b.caller, b.errorResolver, b.contractAddress, - "setVaultStatus", + "revealTaprootDepositWithExtraData", &result, - arg_vault, - arg_isTrusted, + arg_fundingTx, + arg_reveal, + arg_extraData, ) return err } -func (b *Bridge) SetVaultStatusGasEstimate( - arg_vault common.Address, - arg_isTrusted bool, +func (b *Bridge) RevealTaprootDepositWithExtraDataGasEstimate( + arg_fundingTx abi.BitcoinTxInfo, + arg_reveal abi.DepositTaprootDepositRevealInfo, + arg_extraData [32]byte, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "setVaultStatus", + "revealTaprootDepositWithExtraData", b.contractABI, b.transactor, - arg_vault, - arg_isTrusted, + arg_fundingTx, + arg_reveal, + arg_extraData, ) return result, err } // Transaction submission. -func (b *Bridge) SubmitDepositSweepProof( - arg_sweepTx abi.BitcoinTxInfo, - arg_sweepProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, - arg_vault common.Address, +func (b *Bridge) SetEcdsaFraudRouter( + arg_ecdsaFraudRouter common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction submitDepositSweepProof", + "submitting transaction setEcdsaFraudRouter", " params: ", fmt.Sprint( - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, - arg_vault, + arg_ecdsaFraudRouter, ), ) @@ -3502,28 +3280,22 @@ func (b *Bridge) SubmitDepositSweepProof( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SubmitDepositSweepProof( + transaction, err := b.contract.SetEcdsaFraudRouter( transactorOptions, - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, - arg_vault, + arg_ecdsaFraudRouter, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitDepositSweepProof", - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, - arg_vault, + "setEcdsaFraudRouter", + arg_ecdsaFraudRouter, ) } bLogger.Infof( - "submitted transaction submitDepositSweepProof with id: [%s] and nonce [%v]", + "submitted transaction setEcdsaFraudRouter with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3542,28 +3314,22 @@ func (b *Bridge) SubmitDepositSweepProof( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SubmitDepositSweepProof( + transaction, err := b.contract.SetEcdsaFraudRouter( newTransactorOptions, - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, - arg_vault, + arg_ecdsaFraudRouter, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitDepositSweepProof", - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, - arg_vault, + "setEcdsaFraudRouter", + arg_ecdsaFraudRouter, ) } bLogger.Infof( - "submitted transaction submitDepositSweepProof with id: [%s] and nonce [%v]", + "submitted transaction setEcdsaFraudRouter with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3578,11 +3344,8 @@ func (b *Bridge) SubmitDepositSweepProof( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSubmitDepositSweepProof( - arg_sweepTx abi.BitcoinTxInfo, - arg_sweepProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, - arg_vault common.Address, +func (b *Bridge) CallSetEcdsaFraudRouter( + arg_ecdsaFraudRouter common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -3594,55 +3357,42 @@ func (b *Bridge) CallSubmitDepositSweepProof( b.caller, b.errorResolver, b.contractAddress, - "submitDepositSweepProof", + "setEcdsaFraudRouter", &result, - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, - arg_vault, + arg_ecdsaFraudRouter, ) return err } -func (b *Bridge) SubmitDepositSweepProofGasEstimate( - arg_sweepTx abi.BitcoinTxInfo, - arg_sweepProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, - arg_vault common.Address, +func (b *Bridge) SetEcdsaFraudRouterGasEstimate( + arg_ecdsaFraudRouter common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "submitDepositSweepProof", + "setEcdsaFraudRouter", b.contractABI, b.transactor, - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, - arg_vault, + arg_ecdsaFraudRouter, ) return result, err } // Transaction submission. -func (b *Bridge) SubmitFraudChallenge( - arg_walletPublicKey []byte, - arg_preimageSha256 []byte, - arg_signature abi.BitcoinTxRSVSignature, +func (b *Bridge) SetFrostWalletRegistry( + arg_frostWalletRegistry common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction submitFraudChallenge", + "submitting transaction setFrostWalletRegistry", " params: ", fmt.Sprint( - arg_walletPublicKey, - arg_preimageSha256, - arg_signature, + arg_frostWalletRegistry, ), ) @@ -3668,26 +3418,22 @@ func (b *Bridge) SubmitFraudChallenge( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SubmitFraudChallenge( + transaction, err := b.contract.SetFrostWalletRegistry( transactorOptions, - arg_walletPublicKey, - arg_preimageSha256, - arg_signature, + arg_frostWalletRegistry, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitFraudChallenge", - arg_walletPublicKey, - arg_preimageSha256, - arg_signature, + "setFrostWalletRegistry", + arg_frostWalletRegistry, ) } bLogger.Infof( - "submitted transaction submitFraudChallenge with id: [%s] and nonce [%v]", + "submitted transaction setFrostWalletRegistry with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3706,26 +3452,22 @@ func (b *Bridge) SubmitFraudChallenge( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SubmitFraudChallenge( + transaction, err := b.contract.SetFrostWalletRegistry( newTransactorOptions, - arg_walletPublicKey, - arg_preimageSha256, - arg_signature, + arg_frostWalletRegistry, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitFraudChallenge", - arg_walletPublicKey, - arg_preimageSha256, - arg_signature, + "setFrostWalletRegistry", + arg_frostWalletRegistry, ) } bLogger.Infof( - "submitted transaction submitFraudChallenge with id: [%s] and nonce [%v]", + "submitted transaction setFrostWalletRegistry with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3740,10 +3482,8 @@ func (b *Bridge) SubmitFraudChallenge( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSubmitFraudChallenge( - arg_walletPublicKey []byte, - arg_preimageSha256 []byte, - arg_signature abi.BitcoinTxRSVSignature, +func (b *Bridge) CallSetFrostWalletRegistry( + arg_frostWalletRegistry common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -3755,52 +3495,42 @@ func (b *Bridge) CallSubmitFraudChallenge( b.caller, b.errorResolver, b.contractAddress, - "submitFraudChallenge", + "setFrostWalletRegistry", &result, - arg_walletPublicKey, - arg_preimageSha256, - arg_signature, + arg_frostWalletRegistry, ) return err } -func (b *Bridge) SubmitFraudChallengeGasEstimate( - arg_walletPublicKey []byte, - arg_preimageSha256 []byte, - arg_signature abi.BitcoinTxRSVSignature, +func (b *Bridge) SetFrostWalletRegistryGasEstimate( + arg_frostWalletRegistry common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "submitFraudChallenge", + "setFrostWalletRegistry", b.contractABI, b.transactor, - arg_walletPublicKey, - arg_preimageSha256, - arg_signature, + arg_frostWalletRegistry, ) return result, err } // Transaction submission. -func (b *Bridge) SubmitMovedFundsSweepProof( - arg_sweepTx abi.BitcoinTxInfo, - arg_sweepProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, +func (b *Bridge) SetLifecycleRouter( + arg_lifecycleRouter common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction submitMovedFundsSweepProof", + "submitting transaction setLifecycleRouter", " params: ", fmt.Sprint( - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, + arg_lifecycleRouter, ), ) @@ -3826,26 +3556,22 @@ func (b *Bridge) SubmitMovedFundsSweepProof( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SubmitMovedFundsSweepProof( + transaction, err := b.contract.SetLifecycleRouter( transactorOptions, - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, + arg_lifecycleRouter, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitMovedFundsSweepProof", - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, + "setLifecycleRouter", + arg_lifecycleRouter, ) } bLogger.Infof( - "submitted transaction submitMovedFundsSweepProof with id: [%s] and nonce [%v]", + "submitted transaction setLifecycleRouter with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3864,26 +3590,22 @@ func (b *Bridge) SubmitMovedFundsSweepProof( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SubmitMovedFundsSweepProof( + transaction, err := b.contract.SetLifecycleRouter( newTransactorOptions, - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, + arg_lifecycleRouter, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitMovedFundsSweepProof", - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, + "setLifecycleRouter", + arg_lifecycleRouter, ) } bLogger.Infof( - "submitted transaction submitMovedFundsSweepProof with id: [%s] and nonce [%v]", + "submitted transaction setLifecycleRouter with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3898,10 +3620,8 @@ func (b *Bridge) SubmitMovedFundsSweepProof( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSubmitMovedFundsSweepProof( - arg_sweepTx abi.BitcoinTxInfo, - arg_sweepProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, +func (b *Bridge) CallSetLifecycleRouter( + arg_lifecycleRouter common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -3913,56 +3633,42 @@ func (b *Bridge) CallSubmitMovedFundsSweepProof( b.caller, b.errorResolver, b.contractAddress, - "submitMovedFundsSweepProof", + "setLifecycleRouter", &result, - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, + arg_lifecycleRouter, ) return err } -func (b *Bridge) SubmitMovedFundsSweepProofGasEstimate( - arg_sweepTx abi.BitcoinTxInfo, - arg_sweepProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, +func (b *Bridge) SetLifecycleRouterGasEstimate( + arg_lifecycleRouter common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "submitMovedFundsSweepProof", + "setLifecycleRouter", b.contractABI, b.transactor, - arg_sweepTx, - arg_sweepProof, - arg_mainUtxo, + arg_lifecycleRouter, ) return result, err } // Transaction submission. -func (b *Bridge) SubmitMovingFundsCommitment( - arg_walletPubKeyHash [20]byte, - arg_walletMainUtxo abi.BitcoinTxUTXO, - arg_walletMembersIDs []uint32, - arg_walletMemberIndex *big.Int, - arg_targetWallets [][20]byte, +func (b *Bridge) SetP2TRFraudRouter( + arg_p2trFraudRouter common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction submitMovingFundsCommitment", + "submitting transaction setP2TRFraudRouter", " params: ", fmt.Sprint( - arg_walletPubKeyHash, - arg_walletMainUtxo, - arg_walletMembersIDs, - arg_walletMemberIndex, - arg_targetWallets, + arg_p2trFraudRouter, ), ) @@ -3988,30 +3694,22 @@ func (b *Bridge) SubmitMovingFundsCommitment( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SubmitMovingFundsCommitment( + transaction, err := b.contract.SetP2TRFraudRouter( transactorOptions, - arg_walletPubKeyHash, - arg_walletMainUtxo, - arg_walletMembersIDs, - arg_walletMemberIndex, - arg_targetWallets, + arg_p2trFraudRouter, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitMovingFundsCommitment", - arg_walletPubKeyHash, - arg_walletMainUtxo, - arg_walletMembersIDs, - arg_walletMemberIndex, - arg_targetWallets, + "setP2TRFraudRouter", + arg_p2trFraudRouter, ) } bLogger.Infof( - "submitted transaction submitMovingFundsCommitment with id: [%s] and nonce [%v]", + "submitted transaction setP2TRFraudRouter with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4030,30 +3728,22 @@ func (b *Bridge) SubmitMovingFundsCommitment( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SubmitMovingFundsCommitment( + transaction, err := b.contract.SetP2TRFraudRouter( newTransactorOptions, - arg_walletPubKeyHash, - arg_walletMainUtxo, - arg_walletMembersIDs, - arg_walletMemberIndex, - arg_targetWallets, + arg_p2trFraudRouter, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitMovingFundsCommitment", - arg_walletPubKeyHash, - arg_walletMainUtxo, - arg_walletMembersIDs, - arg_walletMemberIndex, - arg_targetWallets, + "setP2TRFraudRouter", + arg_p2trFraudRouter, ) } bLogger.Infof( - "submitted transaction submitMovingFundsCommitment with id: [%s] and nonce [%v]", + "submitted transaction setP2TRFraudRouter with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4068,12 +3758,8 @@ func (b *Bridge) SubmitMovingFundsCommitment( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSubmitMovingFundsCommitment( - arg_walletPubKeyHash [20]byte, - arg_walletMainUtxo abi.BitcoinTxUTXO, - arg_walletMembersIDs []uint32, - arg_walletMemberIndex *big.Int, - arg_targetWallets [][20]byte, +func (b *Bridge) CallSetP2TRFraudRouter( + arg_p2trFraudRouter common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -4085,60 +3771,42 @@ func (b *Bridge) CallSubmitMovingFundsCommitment( b.caller, b.errorResolver, b.contractAddress, - "submitMovingFundsCommitment", + "setP2TRFraudRouter", &result, - arg_walletPubKeyHash, - arg_walletMainUtxo, - arg_walletMembersIDs, - arg_walletMemberIndex, - arg_targetWallets, + arg_p2trFraudRouter, ) return err } -func (b *Bridge) SubmitMovingFundsCommitmentGasEstimate( - arg_walletPubKeyHash [20]byte, - arg_walletMainUtxo abi.BitcoinTxUTXO, - arg_walletMembersIDs []uint32, - arg_walletMemberIndex *big.Int, - arg_targetWallets [][20]byte, +func (b *Bridge) SetP2TRFraudRouterGasEstimate( + arg_p2trFraudRouter common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "submitMovingFundsCommitment", + "setP2TRFraudRouter", b.contractABI, b.transactor, - arg_walletPubKeyHash, - arg_walletMainUtxo, - arg_walletMembersIDs, - arg_walletMemberIndex, - arg_targetWallets, + arg_p2trFraudRouter, ) return result, err } // Transaction submission. -func (b *Bridge) SubmitMovingFundsProof( - arg_movingFundsTx abi.BitcoinTxInfo, - arg_movingFundsProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, - arg_walletPubKeyHash [20]byte, +func (b *Bridge) SetRebateStaking( + arg_rebateStaking common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction submitMovingFundsProof", + "submitting transaction setRebateStaking", " params: ", fmt.Sprint( - arg_movingFundsTx, - arg_movingFundsProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_rebateStaking, ), ) @@ -4164,28 +3832,22 @@ func (b *Bridge) SubmitMovingFundsProof( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SubmitMovingFundsProof( + transaction, err := b.contract.SetRebateStaking( transactorOptions, - arg_movingFundsTx, - arg_movingFundsProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_rebateStaking, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitMovingFundsProof", - arg_movingFundsTx, - arg_movingFundsProof, - arg_mainUtxo, - arg_walletPubKeyHash, + "setRebateStaking", + arg_rebateStaking, ) } bLogger.Infof( - "submitted transaction submitMovingFundsProof with id: [%s] and nonce [%v]", + "submitted transaction setRebateStaking with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4204,28 +3866,22 @@ func (b *Bridge) SubmitMovingFundsProof( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SubmitMovingFundsProof( + transaction, err := b.contract.SetRebateStaking( newTransactorOptions, - arg_movingFundsTx, - arg_movingFundsProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_rebateStaking, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitMovingFundsProof", - arg_movingFundsTx, - arg_movingFundsProof, - arg_mainUtxo, - arg_walletPubKeyHash, + "setRebateStaking", + arg_rebateStaking, ) } bLogger.Infof( - "submitted transaction submitMovingFundsProof with id: [%s] and nonce [%v]", + "submitted transaction setRebateStaking with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4240,11 +3896,8 @@ func (b *Bridge) SubmitMovingFundsProof( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSubmitMovingFundsProof( - arg_movingFundsTx abi.BitcoinTxInfo, - arg_movingFundsProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, - arg_walletPubKeyHash [20]byte, +func (b *Bridge) CallSetRebateStaking( + arg_rebateStaking common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -4256,57 +3909,42 @@ func (b *Bridge) CallSubmitMovingFundsProof( b.caller, b.errorResolver, b.contractAddress, - "submitMovingFundsProof", + "setRebateStaking", &result, - arg_movingFundsTx, - arg_movingFundsProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_rebateStaking, ) return err } -func (b *Bridge) SubmitMovingFundsProofGasEstimate( - arg_movingFundsTx abi.BitcoinTxInfo, - arg_movingFundsProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, - arg_walletPubKeyHash [20]byte, +func (b *Bridge) SetRebateStakingGasEstimate( + arg_rebateStaking common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "submitMovingFundsProof", + "setRebateStaking", b.contractABI, b.transactor, - arg_movingFundsTx, - arg_movingFundsProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_rebateStaking, ) return result, err } // Transaction submission. -func (b *Bridge) SubmitRedemptionProof( - arg_redemptionTx abi.BitcoinTxInfo, - arg_redemptionProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, - arg_walletPubKeyHash [20]byte, +func (b *Bridge) SetRedemptionWatchtower( + arg_redemptionWatchtower common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction submitRedemptionProof", + "submitting transaction setRedemptionWatchtower", " params: ", fmt.Sprint( - arg_redemptionTx, - arg_redemptionProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_redemptionWatchtower, ), ) @@ -4332,28 +3970,22 @@ func (b *Bridge) SubmitRedemptionProof( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SubmitRedemptionProof( + transaction, err := b.contract.SetRedemptionWatchtower( transactorOptions, - arg_redemptionTx, - arg_redemptionProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_redemptionWatchtower, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitRedemptionProof", - arg_redemptionTx, - arg_redemptionProof, - arg_mainUtxo, - arg_walletPubKeyHash, + "setRedemptionWatchtower", + arg_redemptionWatchtower, ) } bLogger.Infof( - "submitted transaction submitRedemptionProof with id: [%s] and nonce [%v]", + "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4372,28 +4004,22 @@ func (b *Bridge) SubmitRedemptionProof( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SubmitRedemptionProof( + transaction, err := b.contract.SetRedemptionWatchtower( newTransactorOptions, - arg_redemptionTx, - arg_redemptionProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_redemptionWatchtower, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "submitRedemptionProof", - arg_redemptionTx, - arg_redemptionProof, - arg_mainUtxo, - arg_walletPubKeyHash, + "setRedemptionWatchtower", + arg_redemptionWatchtower, ) } bLogger.Infof( - "submitted transaction submitRedemptionProof with id: [%s] and nonce [%v]", + "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4408,11 +4034,8 @@ func (b *Bridge) SubmitRedemptionProof( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSubmitRedemptionProof( - arg_redemptionTx abi.BitcoinTxInfo, - arg_redemptionProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, - arg_walletPubKeyHash [20]byte, +func (b *Bridge) CallSetRedemptionWatchtower( + arg_redemptionWatchtower common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -4424,51 +4047,44 @@ func (b *Bridge) CallSubmitRedemptionProof( b.caller, b.errorResolver, b.contractAddress, - "submitRedemptionProof", + "setRedemptionWatchtower", &result, - arg_redemptionTx, - arg_redemptionProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_redemptionWatchtower, ) return err } -func (b *Bridge) SubmitRedemptionProofGasEstimate( - arg_redemptionTx abi.BitcoinTxInfo, - arg_redemptionProof abi.BitcoinTxProof, - arg_mainUtxo abi.BitcoinTxUTXO, - arg_walletPubKeyHash [20]byte, +func (b *Bridge) SetRedemptionWatchtowerGasEstimate( + arg_redemptionWatchtower common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "submitRedemptionProof", + "setRedemptionWatchtower", b.contractABI, b.transactor, - arg_redemptionTx, - arg_redemptionProof, - arg_mainUtxo, - arg_walletPubKeyHash, + arg_redemptionWatchtower, ) return result, err } // Transaction submission. -func (b *Bridge) TransferGovernance( - arg_newGovernance common.Address, +func (b *Bridge) SetSpvMaintainerStatus( + arg_spvMaintainer common.Address, + arg_isTrusted bool, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction transferGovernance", + "submitting transaction setSpvMaintainerStatus", " params: ", fmt.Sprint( - arg_newGovernance, + arg_spvMaintainer, + arg_isTrusted, ), ) @@ -4494,22 +4110,24 @@ func (b *Bridge) TransferGovernance( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.TransferGovernance( + transaction, err := b.contract.SetSpvMaintainerStatus( transactorOptions, - arg_newGovernance, + arg_spvMaintainer, + arg_isTrusted, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "transferGovernance", - arg_newGovernance, + "setSpvMaintainerStatus", + arg_spvMaintainer, + arg_isTrusted, ) } bLogger.Infof( - "submitted transaction transferGovernance with id: [%s] and nonce [%v]", + "submitted transaction setSpvMaintainerStatus with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4528,22 +4146,24 @@ func (b *Bridge) TransferGovernance( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.TransferGovernance( + transaction, err := b.contract.SetSpvMaintainerStatus( newTransactorOptions, - arg_newGovernance, + arg_spvMaintainer, + arg_isTrusted, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "transferGovernance", - arg_newGovernance, + "setSpvMaintainerStatus", + arg_spvMaintainer, + arg_isTrusted, ) } bLogger.Infof( - "submitted transaction transferGovernance with id: [%s] and nonce [%v]", + "submitted transaction setSpvMaintainerStatus with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4558,8 +4178,9 @@ func (b *Bridge) TransferGovernance( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallTransferGovernance( - arg_newGovernance common.Address, +func (b *Bridge) CallSetSpvMaintainerStatus( + arg_spvMaintainer common.Address, + arg_isTrusted bool, blockNumber *big.Int, ) error { var result interface{} = nil @@ -4571,48 +4192,47 @@ func (b *Bridge) CallTransferGovernance( b.caller, b.errorResolver, b.contractAddress, - "transferGovernance", + "setSpvMaintainerStatus", &result, - arg_newGovernance, + arg_spvMaintainer, + arg_isTrusted, ) return err } -func (b *Bridge) TransferGovernanceGasEstimate( - arg_newGovernance common.Address, +func (b *Bridge) SetSpvMaintainerStatusGasEstimate( + arg_spvMaintainer common.Address, + arg_isTrusted bool, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "transferGovernance", + "setSpvMaintainerStatus", b.contractABI, b.transactor, - arg_newGovernance, + arg_spvMaintainer, + arg_isTrusted, ) return result, err } // Transaction submission. -func (b *Bridge) UpdateDepositParameters( - arg_depositDustThreshold uint64, - arg_depositTreasuryFeeDivisor uint64, - arg_depositTxMaxFee uint64, - arg_depositRevealAheadPeriod uint32, +func (b *Bridge) SetVaultStatus( + arg_vault common.Address, + arg_isTrusted bool, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction updateDepositParameters", + "submitting transaction setVaultStatus", " params: ", fmt.Sprint( - arg_depositDustThreshold, - arg_depositTreasuryFeeDivisor, - arg_depositTxMaxFee, - arg_depositRevealAheadPeriod, + arg_vault, + arg_isTrusted, ), ) @@ -4638,28 +4258,24 @@ func (b *Bridge) UpdateDepositParameters( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.UpdateDepositParameters( + transaction, err := b.contract.SetVaultStatus( transactorOptions, - arg_depositDustThreshold, - arg_depositTreasuryFeeDivisor, - arg_depositTxMaxFee, - arg_depositRevealAheadPeriod, + arg_vault, + arg_isTrusted, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateDepositParameters", - arg_depositDustThreshold, - arg_depositTreasuryFeeDivisor, - arg_depositTxMaxFee, - arg_depositRevealAheadPeriod, + "setVaultStatus", + arg_vault, + arg_isTrusted, ) } bLogger.Infof( - "submitted transaction updateDepositParameters with id: [%s] and nonce [%v]", + "submitted transaction setVaultStatus with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4678,28 +4294,24 @@ func (b *Bridge) UpdateDepositParameters( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.UpdateDepositParameters( + transaction, err := b.contract.SetVaultStatus( newTransactorOptions, - arg_depositDustThreshold, - arg_depositTreasuryFeeDivisor, - arg_depositTxMaxFee, - arg_depositRevealAheadPeriod, + arg_vault, + arg_isTrusted, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateDepositParameters", - arg_depositDustThreshold, - arg_depositTreasuryFeeDivisor, - arg_depositTxMaxFee, - arg_depositRevealAheadPeriod, + "setVaultStatus", + arg_vault, + arg_isTrusted, ) } bLogger.Infof( - "submitted transaction updateDepositParameters with id: [%s] and nonce [%v]", + "submitted transaction setVaultStatus with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4714,11 +4326,9 @@ func (b *Bridge) UpdateDepositParameters( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallUpdateDepositParameters( - arg_depositDustThreshold uint64, - arg_depositTreasuryFeeDivisor uint64, - arg_depositTxMaxFee uint64, - arg_depositRevealAheadPeriod uint32, +func (b *Bridge) CallSetVaultStatus( + arg_vault common.Address, + arg_isTrusted bool, blockNumber *big.Int, ) error { var result interface{} = nil @@ -4730,57 +4340,49 @@ func (b *Bridge) CallUpdateDepositParameters( b.caller, b.errorResolver, b.contractAddress, - "updateDepositParameters", + "setVaultStatus", &result, - arg_depositDustThreshold, - arg_depositTreasuryFeeDivisor, - arg_depositTxMaxFee, - arg_depositRevealAheadPeriod, + arg_vault, + arg_isTrusted, ) return err } -func (b *Bridge) UpdateDepositParametersGasEstimate( - arg_depositDustThreshold uint64, - arg_depositTreasuryFeeDivisor uint64, - arg_depositTxMaxFee uint64, - arg_depositRevealAheadPeriod uint32, +func (b *Bridge) SetVaultStatusGasEstimate( + arg_vault common.Address, + arg_isTrusted bool, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "updateDepositParameters", + "setVaultStatus", b.contractABI, b.transactor, - arg_depositDustThreshold, - arg_depositTreasuryFeeDivisor, - arg_depositTxMaxFee, - arg_depositRevealAheadPeriod, + arg_vault, + arg_isTrusted, ) return result, err } // Transaction submission. -func (b *Bridge) UpdateFraudParameters( - arg_fraudChallengeDepositAmount *big.Int, - arg_fraudChallengeDefeatTimeout uint32, - arg_fraudSlashingAmount *big.Int, - arg_fraudNotifierRewardMultiplier uint32, +func (b *Bridge) SlashWalletForFraud( + arg_walletPubKeyHash [20]byte, + arg_walletMembersIDs []uint32, + arg_challenger common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction updateFraudParameters", + "submitting transaction slashWalletForFraud", " params: ", fmt.Sprint( - arg_fraudChallengeDepositAmount, - arg_fraudChallengeDefeatTimeout, - arg_fraudSlashingAmount, - arg_fraudNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ), ) @@ -4806,28 +4408,26 @@ func (b *Bridge) UpdateFraudParameters( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.UpdateFraudParameters( + transaction, err := b.contract.SlashWalletForFraud( transactorOptions, - arg_fraudChallengeDepositAmount, - arg_fraudChallengeDefeatTimeout, - arg_fraudSlashingAmount, - arg_fraudNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateFraudParameters", - arg_fraudChallengeDepositAmount, - arg_fraudChallengeDefeatTimeout, - arg_fraudSlashingAmount, - arg_fraudNotifierRewardMultiplier, + "slashWalletForFraud", + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) } bLogger.Infof( - "submitted transaction updateFraudParameters with id: [%s] and nonce [%v]", + "submitted transaction slashWalletForFraud with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4846,28 +4446,26 @@ func (b *Bridge) UpdateFraudParameters( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.UpdateFraudParameters( + transaction, err := b.contract.SlashWalletForFraud( newTransactorOptions, - arg_fraudChallengeDepositAmount, - arg_fraudChallengeDefeatTimeout, - arg_fraudSlashingAmount, - arg_fraudNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateFraudParameters", - arg_fraudChallengeDepositAmount, - arg_fraudChallengeDefeatTimeout, - arg_fraudSlashingAmount, - arg_fraudNotifierRewardMultiplier, + "slashWalletForFraud", + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) } bLogger.Infof( - "submitted transaction updateFraudParameters with id: [%s] and nonce [%v]", + "submitted transaction slashWalletForFraud with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -4882,11 +4480,10 @@ func (b *Bridge) UpdateFraudParameters( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallUpdateFraudParameters( - arg_fraudChallengeDepositAmount *big.Int, - arg_fraudChallengeDefeatTimeout uint32, - arg_fraudSlashingAmount *big.Int, - arg_fraudNotifierRewardMultiplier uint32, +func (b *Bridge) CallSlashWalletForFraud( + arg_walletPubKeyHash [20]byte, + arg_walletMembersIDs []uint32, + arg_challenger common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -4898,71 +4495,52 @@ func (b *Bridge) CallUpdateFraudParameters( b.caller, b.errorResolver, b.contractAddress, - "updateFraudParameters", + "slashWalletForFraud", &result, - arg_fraudChallengeDepositAmount, - arg_fraudChallengeDefeatTimeout, - arg_fraudSlashingAmount, - arg_fraudNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) return err } -func (b *Bridge) UpdateFraudParametersGasEstimate( - arg_fraudChallengeDepositAmount *big.Int, - arg_fraudChallengeDefeatTimeout uint32, - arg_fraudSlashingAmount *big.Int, - arg_fraudNotifierRewardMultiplier uint32, +func (b *Bridge) SlashWalletForFraudGasEstimate( + arg_walletPubKeyHash [20]byte, + arg_walletMembersIDs []uint32, + arg_challenger common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "updateFraudParameters", + "slashWalletForFraud", b.contractABI, b.transactor, - arg_fraudChallengeDepositAmount, - arg_fraudChallengeDefeatTimeout, - arg_fraudSlashingAmount, - arg_fraudNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) return result, err } // Transaction submission. -func (b *Bridge) UpdateMovingFundsParameters( - arg_movingFundsTxMaxTotalFee uint64, - arg_movingFundsDustThreshold uint64, - arg_movingFundsTimeoutResetDelay uint32, - arg_movingFundsTimeout uint32, - arg_movingFundsTimeoutSlashingAmount *big.Int, - arg_movingFundsTimeoutNotifierRewardMultiplier uint32, - arg_movingFundsCommitmentGasOffset uint16, - arg_movedFundsSweepTxMaxTotalFee uint64, - arg_movedFundsSweepTimeout uint32, - arg_movedFundsSweepTimeoutSlashingAmount *big.Int, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier uint32, +func (b *Bridge) SlashWalletForP2TRFraud( + arg_walletPubKeyHash [20]byte, + arg_walletMembersIDs []uint32, + arg_challenger common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction updateMovingFundsParameters", + "submitting transaction slashWalletForP2TRFraud", " params: ", fmt.Sprint( - arg_movingFundsTxMaxTotalFee, - arg_movingFundsDustThreshold, - arg_movingFundsTimeoutResetDelay, - arg_movingFundsTimeout, - arg_movingFundsTimeoutSlashingAmount, - arg_movingFundsTimeoutNotifierRewardMultiplier, - arg_movingFundsCommitmentGasOffset, - arg_movedFundsSweepTxMaxTotalFee, - arg_movedFundsSweepTimeout, - arg_movedFundsSweepTimeoutSlashingAmount, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ), ) @@ -4988,42 +4566,26 @@ func (b *Bridge) UpdateMovingFundsParameters( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.UpdateMovingFundsParameters( + transaction, err := b.contract.SlashWalletForP2TRFraud( transactorOptions, - arg_movingFundsTxMaxTotalFee, - arg_movingFundsDustThreshold, - arg_movingFundsTimeoutResetDelay, - arg_movingFundsTimeout, - arg_movingFundsTimeoutSlashingAmount, - arg_movingFundsTimeoutNotifierRewardMultiplier, - arg_movingFundsCommitmentGasOffset, - arg_movedFundsSweepTxMaxTotalFee, - arg_movedFundsSweepTimeout, - arg_movedFundsSweepTimeoutSlashingAmount, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateMovingFundsParameters", - arg_movingFundsTxMaxTotalFee, - arg_movingFundsDustThreshold, - arg_movingFundsTimeoutResetDelay, - arg_movingFundsTimeout, - arg_movingFundsTimeoutSlashingAmount, - arg_movingFundsTimeoutNotifierRewardMultiplier, - arg_movingFundsCommitmentGasOffset, - arg_movedFundsSweepTxMaxTotalFee, - arg_movedFundsSweepTimeout, - arg_movedFundsSweepTimeoutSlashingAmount, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + "slashWalletForP2TRFraud", + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) } bLogger.Infof( - "submitted transaction updateMovingFundsParameters with id: [%s] and nonce [%v]", + "submitted transaction slashWalletForP2TRFraud with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -5042,42 +4604,26 @@ func (b *Bridge) UpdateMovingFundsParameters( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.UpdateMovingFundsParameters( + transaction, err := b.contract.SlashWalletForP2TRFraud( newTransactorOptions, - arg_movingFundsTxMaxTotalFee, - arg_movingFundsDustThreshold, - arg_movingFundsTimeoutResetDelay, - arg_movingFundsTimeout, - arg_movingFundsTimeoutSlashingAmount, - arg_movingFundsTimeoutNotifierRewardMultiplier, - arg_movingFundsCommitmentGasOffset, - arg_movedFundsSweepTxMaxTotalFee, - arg_movedFundsSweepTimeout, - arg_movedFundsSweepTimeoutSlashingAmount, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateMovingFundsParameters", - arg_movingFundsTxMaxTotalFee, - arg_movingFundsDustThreshold, - arg_movingFundsTimeoutResetDelay, - arg_movingFundsTimeout, - arg_movingFundsTimeoutSlashingAmount, - arg_movingFundsTimeoutNotifierRewardMultiplier, - arg_movingFundsCommitmentGasOffset, - arg_movedFundsSweepTxMaxTotalFee, - arg_movedFundsSweepTimeout, - arg_movedFundsSweepTimeoutSlashingAmount, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + "slashWalletForP2TRFraud", + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) } bLogger.Infof( - "submitted transaction updateMovingFundsParameters with id: [%s] and nonce [%v]", + "submitted transaction slashWalletForP2TRFraud with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -5092,18 +4638,10 @@ func (b *Bridge) UpdateMovingFundsParameters( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallUpdateMovingFundsParameters( - arg_movingFundsTxMaxTotalFee uint64, - arg_movingFundsDustThreshold uint64, - arg_movingFundsTimeoutResetDelay uint32, - arg_movingFundsTimeout uint32, - arg_movingFundsTimeoutSlashingAmount *big.Int, - arg_movingFundsTimeoutNotifierRewardMultiplier uint32, - arg_movingFundsCommitmentGasOffset uint16, - arg_movedFundsSweepTxMaxTotalFee uint64, - arg_movedFundsSweepTimeout uint32, - arg_movedFundsSweepTimeoutSlashingAmount *big.Int, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier uint32, +func (b *Bridge) CallSlashWalletForP2TRFraud( + arg_walletPubKeyHash [20]byte, + arg_walletMembersIDs []uint32, + arg_challenger common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -5115,84 +4653,54 @@ func (b *Bridge) CallUpdateMovingFundsParameters( b.caller, b.errorResolver, b.contractAddress, - "updateMovingFundsParameters", + "slashWalletForP2TRFraud", &result, - arg_movingFundsTxMaxTotalFee, - arg_movingFundsDustThreshold, - arg_movingFundsTimeoutResetDelay, - arg_movingFundsTimeout, - arg_movingFundsTimeoutSlashingAmount, - arg_movingFundsTimeoutNotifierRewardMultiplier, - arg_movingFundsCommitmentGasOffset, - arg_movedFundsSweepTxMaxTotalFee, - arg_movedFundsSweepTimeout, - arg_movedFundsSweepTimeoutSlashingAmount, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) return err } -func (b *Bridge) UpdateMovingFundsParametersGasEstimate( - arg_movingFundsTxMaxTotalFee uint64, - arg_movingFundsDustThreshold uint64, - arg_movingFundsTimeoutResetDelay uint32, - arg_movingFundsTimeout uint32, - arg_movingFundsTimeoutSlashingAmount *big.Int, - arg_movingFundsTimeoutNotifierRewardMultiplier uint32, - arg_movingFundsCommitmentGasOffset uint16, - arg_movedFundsSweepTxMaxTotalFee uint64, - arg_movedFundsSweepTimeout uint32, - arg_movedFundsSweepTimeoutSlashingAmount *big.Int, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier uint32, +func (b *Bridge) SlashWalletForP2TRFraudGasEstimate( + arg_walletPubKeyHash [20]byte, + arg_walletMembersIDs []uint32, + arg_challenger common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "updateMovingFundsParameters", + "slashWalletForP2TRFraud", b.contractABI, b.transactor, - arg_movingFundsTxMaxTotalFee, - arg_movingFundsDustThreshold, - arg_movingFundsTimeoutResetDelay, - arg_movingFundsTimeout, - arg_movingFundsTimeoutSlashingAmount, - arg_movingFundsTimeoutNotifierRewardMultiplier, - arg_movingFundsCommitmentGasOffset, - arg_movedFundsSweepTxMaxTotalFee, - arg_movedFundsSweepTimeout, - arg_movedFundsSweepTimeoutSlashingAmount, - arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + arg_walletPubKeyHash, + arg_walletMembersIDs, + arg_challenger, ) return result, err } // Transaction submission. -func (b *Bridge) UpdateRedemptionParameters( - arg_redemptionDustThreshold uint64, - arg_redemptionTreasuryFeeDivisor uint64, - arg_redemptionTxMaxFee uint64, - arg_redemptionTxMaxTotalFee uint64, - arg_redemptionTimeout uint32, - arg_redemptionTimeoutSlashingAmount *big.Int, - arg_redemptionTimeoutNotifierRewardMultiplier uint32, +func (b *Bridge) SubmitDepositSweepProof( + arg_sweepTx abi.BitcoinTxInfo, + arg_sweepProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, + arg_vault common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction updateRedemptionParameters", + "submitting transaction submitDepositSweepProof", " params: ", fmt.Sprint( - arg_redemptionDustThreshold, - arg_redemptionTreasuryFeeDivisor, - arg_redemptionTxMaxFee, - arg_redemptionTxMaxTotalFee, - arg_redemptionTimeout, - arg_redemptionTimeoutSlashingAmount, - arg_redemptionTimeoutNotifierRewardMultiplier, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, + arg_vault, ), ) @@ -5218,34 +4726,28 @@ func (b *Bridge) UpdateRedemptionParameters( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.UpdateRedemptionParameters( + transaction, err := b.contract.SubmitDepositSweepProof( transactorOptions, - arg_redemptionDustThreshold, - arg_redemptionTreasuryFeeDivisor, - arg_redemptionTxMaxFee, - arg_redemptionTxMaxTotalFee, - arg_redemptionTimeout, - arg_redemptionTimeoutSlashingAmount, - arg_redemptionTimeoutNotifierRewardMultiplier, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, + arg_vault, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateRedemptionParameters", - arg_redemptionDustThreshold, - arg_redemptionTreasuryFeeDivisor, - arg_redemptionTxMaxFee, - arg_redemptionTxMaxTotalFee, - arg_redemptionTimeout, - arg_redemptionTimeoutSlashingAmount, - arg_redemptionTimeoutNotifierRewardMultiplier, + "submitDepositSweepProof", + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, + arg_vault, ) } bLogger.Infof( - "submitted transaction updateRedemptionParameters with id: [%s] and nonce [%v]", + "submitted transaction submitDepositSweepProof with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -5264,34 +4766,28 @@ func (b *Bridge) UpdateRedemptionParameters( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.UpdateRedemptionParameters( + transaction, err := b.contract.SubmitDepositSweepProof( newTransactorOptions, - arg_redemptionDustThreshold, - arg_redemptionTreasuryFeeDivisor, - arg_redemptionTxMaxFee, - arg_redemptionTxMaxTotalFee, - arg_redemptionTimeout, - arg_redemptionTimeoutSlashingAmount, - arg_redemptionTimeoutNotifierRewardMultiplier, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, + arg_vault, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateRedemptionParameters", - arg_redemptionDustThreshold, - arg_redemptionTreasuryFeeDivisor, - arg_redemptionTxMaxFee, - arg_redemptionTxMaxTotalFee, - arg_redemptionTimeout, - arg_redemptionTimeoutSlashingAmount, - arg_redemptionTimeoutNotifierRewardMultiplier, + "submitDepositSweepProof", + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, + arg_vault, ) } bLogger.Infof( - "submitted transaction updateRedemptionParameters with id: [%s] and nonce [%v]", + "submitted transaction submitDepositSweepProof with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -5306,14 +4802,11 @@ func (b *Bridge) UpdateRedemptionParameters( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallUpdateRedemptionParameters( - arg_redemptionDustThreshold uint64, - arg_redemptionTreasuryFeeDivisor uint64, - arg_redemptionTxMaxFee uint64, - arg_redemptionTxMaxTotalFee uint64, - arg_redemptionTimeout uint32, - arg_redemptionTimeoutSlashingAmount *big.Int, - arg_redemptionTimeoutNotifierRewardMultiplier uint32, +func (b *Bridge) CallSubmitDepositSweepProof( + arg_sweepTx abi.BitcoinTxInfo, + arg_sweepProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, + arg_vault common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -5325,60 +4818,55 @@ func (b *Bridge) CallUpdateRedemptionParameters( b.caller, b.errorResolver, b.contractAddress, - "updateRedemptionParameters", + "submitDepositSweepProof", &result, - arg_redemptionDustThreshold, - arg_redemptionTreasuryFeeDivisor, - arg_redemptionTxMaxFee, - arg_redemptionTxMaxTotalFee, - arg_redemptionTimeout, - arg_redemptionTimeoutSlashingAmount, - arg_redemptionTimeoutNotifierRewardMultiplier, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, + arg_vault, ) return err } -func (b *Bridge) UpdateRedemptionParametersGasEstimate( - arg_redemptionDustThreshold uint64, - arg_redemptionTreasuryFeeDivisor uint64, - arg_redemptionTxMaxFee uint64, - arg_redemptionTxMaxTotalFee uint64, - arg_redemptionTimeout uint32, - arg_redemptionTimeoutSlashingAmount *big.Int, - arg_redemptionTimeoutNotifierRewardMultiplier uint32, +func (b *Bridge) SubmitDepositSweepProofGasEstimate( + arg_sweepTx abi.BitcoinTxInfo, + arg_sweepProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, + arg_vault common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "updateRedemptionParameters", + "submitDepositSweepProof", b.contractABI, b.transactor, - arg_redemptionDustThreshold, - arg_redemptionTreasuryFeeDivisor, - arg_redemptionTxMaxFee, - arg_redemptionTxMaxTotalFee, - arg_redemptionTimeout, - arg_redemptionTimeoutSlashingAmount, - arg_redemptionTimeoutNotifierRewardMultiplier, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, + arg_vault, ) return result, err } // Transaction submission. -func (b *Bridge) UpdateTreasury( - arg_treasury common.Address, +func (b *Bridge) SubmitMovedFundsSweepProof( + arg_sweepTx abi.BitcoinTxInfo, + arg_sweepProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction updateTreasury", + "submitting transaction submitMovedFundsSweepProof", " params: ", fmt.Sprint( - arg_treasury, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, ), ) @@ -5404,22 +4892,26 @@ func (b *Bridge) UpdateTreasury( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.UpdateTreasury( + transaction, err := b.contract.SubmitMovedFundsSweepProof( transactorOptions, - arg_treasury, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateTreasury", - arg_treasury, + "submitMovedFundsSweepProof", + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, ) } bLogger.Infof( - "submitted transaction updateTreasury with id: [%s] and nonce [%v]", + "submitted transaction submitMovedFundsSweepProof with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -5438,22 +4930,26 @@ func (b *Bridge) UpdateTreasury( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.UpdateTreasury( + transaction, err := b.contract.SubmitMovedFundsSweepProof( newTransactorOptions, - arg_treasury, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateTreasury", - arg_treasury, + "submitMovedFundsSweepProof", + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, ) } bLogger.Infof( - "submitted transaction updateTreasury with id: [%s] and nonce [%v]", + "submitted transaction submitMovedFundsSweepProof with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -5468,8 +4964,10 @@ func (b *Bridge) UpdateTreasury( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallUpdateTreasury( - arg_treasury common.Address, +func (b *Bridge) CallSubmitMovedFundsSweepProof( + arg_sweepTx abi.BitcoinTxInfo, + arg_sweepProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, blockNumber *big.Int, ) error { var result interface{} = nil @@ -5481,54 +4979,56 @@ func (b *Bridge) CallUpdateTreasury( b.caller, b.errorResolver, b.contractAddress, - "updateTreasury", + "submitMovedFundsSweepProof", &result, - arg_treasury, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, ) return err } -func (b *Bridge) UpdateTreasuryGasEstimate( - arg_treasury common.Address, +func (b *Bridge) SubmitMovedFundsSweepProofGasEstimate( + arg_sweepTx abi.BitcoinTxInfo, + arg_sweepProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "updateTreasury", + "submitMovedFundsSweepProof", b.contractABI, b.transactor, - arg_treasury, + arg_sweepTx, + arg_sweepProof, + arg_mainUtxo, ) return result, err } // Transaction submission. -func (b *Bridge) UpdateWalletParameters( - arg_walletCreationPeriod uint32, - arg_walletCreationMinBtcBalance uint64, - arg_walletCreationMaxBtcBalance uint64, - arg_walletClosureMinBtcBalance uint64, - arg_walletMaxAge uint32, - arg_walletMaxBtcTransfer uint64, - arg_walletClosingPeriod uint32, +func (b *Bridge) SubmitMovingFundsCommitment( + arg_walletPubKeyHash [20]byte, + arg_walletMainUtxo abi.BitcoinTxUTXO, + arg_walletMembersIDs []uint32, + arg_walletMemberIndex *big.Int, + arg_targetWallets [][20]byte, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction updateWalletParameters", + "submitting transaction submitMovingFundsCommitment", " params: ", fmt.Sprint( - arg_walletCreationPeriod, - arg_walletCreationMinBtcBalance, - arg_walletCreationMaxBtcBalance, - arg_walletClosureMinBtcBalance, - arg_walletMaxAge, - arg_walletMaxBtcTransfer, - arg_walletClosingPeriod, + arg_walletPubKeyHash, + arg_walletMainUtxo, + arg_walletMembersIDs, + arg_walletMemberIndex, + arg_targetWallets, ), ) @@ -5554,34 +5054,30 @@ func (b *Bridge) UpdateWalletParameters( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.UpdateWalletParameters( + transaction, err := b.contract.SubmitMovingFundsCommitment( transactorOptions, - arg_walletCreationPeriod, - arg_walletCreationMinBtcBalance, - arg_walletCreationMaxBtcBalance, - arg_walletClosureMinBtcBalance, - arg_walletMaxAge, - arg_walletMaxBtcTransfer, - arg_walletClosingPeriod, + arg_walletPubKeyHash, + arg_walletMainUtxo, + arg_walletMembersIDs, + arg_walletMemberIndex, + arg_targetWallets, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateWalletParameters", - arg_walletCreationPeriod, - arg_walletCreationMinBtcBalance, - arg_walletCreationMaxBtcBalance, - arg_walletClosureMinBtcBalance, - arg_walletMaxAge, - arg_walletMaxBtcTransfer, - arg_walletClosingPeriod, + "submitMovingFundsCommitment", + arg_walletPubKeyHash, + arg_walletMainUtxo, + arg_walletMembersIDs, + arg_walletMemberIndex, + arg_targetWallets, ) } bLogger.Infof( - "submitted transaction updateWalletParameters with id: [%s] and nonce [%v]", + "submitted transaction submitMovingFundsCommitment with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -5600,34 +5096,30 @@ func (b *Bridge) UpdateWalletParameters( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.UpdateWalletParameters( + transaction, err := b.contract.SubmitMovingFundsCommitment( newTransactorOptions, - arg_walletCreationPeriod, - arg_walletCreationMinBtcBalance, - arg_walletCreationMaxBtcBalance, - arg_walletClosureMinBtcBalance, - arg_walletMaxAge, - arg_walletMaxBtcTransfer, - arg_walletClosingPeriod, + arg_walletPubKeyHash, + arg_walletMainUtxo, + arg_walletMembersIDs, + arg_walletMemberIndex, + arg_targetWallets, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "updateWalletParameters", - arg_walletCreationPeriod, - arg_walletCreationMinBtcBalance, - arg_walletCreationMaxBtcBalance, - arg_walletClosureMinBtcBalance, - arg_walletMaxAge, - arg_walletMaxBtcTransfer, - arg_walletClosingPeriod, + "submitMovingFundsCommitment", + arg_walletPubKeyHash, + arg_walletMainUtxo, + arg_walletMembersIDs, + arg_walletMemberIndex, + arg_targetWallets, ) } bLogger.Infof( - "submitted transaction updateWalletParameters with id: [%s] and nonce [%v]", + "submitted transaction submitMovingFundsCommitment with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -5642,14 +5134,12 @@ func (b *Bridge) UpdateWalletParameters( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallUpdateWalletParameters( - arg_walletCreationPeriod uint32, - arg_walletCreationMinBtcBalance uint64, - arg_walletCreationMaxBtcBalance uint64, - arg_walletClosureMinBtcBalance uint64, - arg_walletMaxAge uint32, - arg_walletMaxBtcTransfer uint64, - arg_walletClosingPeriod uint32, +func (b *Bridge) CallSubmitMovingFundsCommitment( + arg_walletPubKeyHash [20]byte, + arg_walletMainUtxo abi.BitcoinTxUTXO, + arg_walletMembersIDs []uint32, + arg_walletMemberIndex *big.Int, + arg_targetWallets [][20]byte, blockNumber *big.Int, ) error { var result interface{} = nil @@ -5661,899 +5151,4283 @@ func (b *Bridge) CallUpdateWalletParameters( b.caller, b.errorResolver, b.contractAddress, - "updateWalletParameters", + "submitMovingFundsCommitment", &result, - arg_walletCreationPeriod, - arg_walletCreationMinBtcBalance, - arg_walletCreationMaxBtcBalance, - arg_walletClosureMinBtcBalance, - arg_walletMaxAge, - arg_walletMaxBtcTransfer, - arg_walletClosingPeriod, + arg_walletPubKeyHash, + arg_walletMainUtxo, + arg_walletMembersIDs, + arg_walletMemberIndex, + arg_targetWallets, ) return err } -func (b *Bridge) UpdateWalletParametersGasEstimate( - arg_walletCreationPeriod uint32, - arg_walletCreationMinBtcBalance uint64, - arg_walletCreationMaxBtcBalance uint64, - arg_walletClosureMinBtcBalance uint64, - arg_walletMaxAge uint32, - arg_walletMaxBtcTransfer uint64, - arg_walletClosingPeriod uint32, +func (b *Bridge) SubmitMovingFundsCommitmentGasEstimate( + arg_walletPubKeyHash [20]byte, + arg_walletMainUtxo abi.BitcoinTxUTXO, + arg_walletMembersIDs []uint32, + arg_walletMemberIndex *big.Int, + arg_targetWallets [][20]byte, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "updateWalletParameters", + "submitMovingFundsCommitment", b.contractABI, b.transactor, - arg_walletCreationPeriod, - arg_walletCreationMinBtcBalance, - arg_walletCreationMaxBtcBalance, - arg_walletClosureMinBtcBalance, - arg_walletMaxAge, - arg_walletMaxBtcTransfer, - arg_walletClosingPeriod, + arg_walletPubKeyHash, + arg_walletMainUtxo, + arg_walletMembersIDs, + arg_walletMemberIndex, + arg_targetWallets, ) return result, err } -// ----- Const Methods ------ +// Transaction submission. +func (b *Bridge) SubmitMovingFundsProof( + arg_movingFundsTx abi.BitcoinTxInfo, + arg_movingFundsProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, + arg_walletPubKeyHash [20]byte, -func (b *Bridge) ActiveWalletPubKeyHash() ([20]byte, error) { - result, err := b.contract.ActiveWalletPubKeyHash( - b.callerOptions, + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction submitMovingFundsProof", + " params: ", + fmt.Sprint( + arg_movingFundsTx, + arg_movingFundsProof, + arg_mainUtxo, + arg_walletPubKeyHash, + ), ) + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() if err != nil { - return result, b.errorResolver.ResolveError( + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.SubmitMovingFundsProof( + transactorOptions, + arg_movingFundsTx, + arg_movingFundsProof, + arg_mainUtxo, + arg_walletPubKeyHash, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( err, - b.callerOptions.From, + b.transactorOptions.From, nil, - "activeWalletPubKeyHash", + "submitMovingFundsProof", + arg_movingFundsTx, + arg_movingFundsProof, + arg_mainUtxo, + arg_walletPubKeyHash, ) } - return result, err -} - -func (b *Bridge) ActiveWalletPubKeyHashAtBlock( - blockNumber *big.Int, -) ([20]byte, error) { - var result [20]byte - - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "activeWalletPubKeyHash", - &result, + bLogger.Infof( + "submitted transaction submitMovingFundsProof with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), ) - return result, err -} + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } -type contractReferences struct { - Bank common.Address - Relay common.Address - EcdsaWalletRegistry common.Address - ReimbursementPool common.Address -} + transaction, err := b.contract.SubmitMovingFundsProof( + newTransactorOptions, + arg_movingFundsTx, + arg_movingFundsProof, + arg_mainUtxo, + arg_walletPubKeyHash, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "submitMovingFundsProof", + arg_movingFundsTx, + arg_movingFundsProof, + arg_mainUtxo, + arg_walletPubKeyHash, + ) + } -func (b *Bridge) ContractReferences() (contractReferences, error) { - result, err := b.contract.ContractReferences( - b.callerOptions, + bLogger.Infof( + "submitted transaction submitMovingFundsProof with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, ) - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "contractReferences", - ) - } + b.nonceManager.IncrementNonce() - return result, err + return transaction, err } -func (b *Bridge) ContractReferencesAtBlock( +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallSubmitMovingFundsProof( + arg_movingFundsTx abi.BitcoinTxInfo, + arg_movingFundsProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, + arg_walletPubKeyHash [20]byte, blockNumber *big.Int, -) (contractReferences, error) { - var result contractReferences +) error { + var result interface{} = nil err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, + b.transactorOptions.From, + blockNumber, nil, b.contractABI, b.caller, b.errorResolver, b.contractAddress, - "contractReferences", + "submitMovingFundsProof", &result, + arg_movingFundsTx, + arg_movingFundsProof, + arg_mainUtxo, + arg_walletPubKeyHash, ) - return result, err + return err } -type depositParameters struct { - DepositDustThreshold uint64 - DepositTreasuryFeeDivisor uint64 - DepositTxMaxFee uint64 - DepositRevealAheadPeriod uint32 -} +func (b *Bridge) SubmitMovingFundsProofGasEstimate( + arg_movingFundsTx abi.BitcoinTxInfo, + arg_movingFundsProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, + arg_walletPubKeyHash [20]byte, +) (uint64, error) { + var result uint64 -func (b *Bridge) DepositParameters() (depositParameters, error) { - result, err := b.contract.DepositParameters( - b.callerOptions, + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "submitMovingFundsProof", + b.contractABI, + b.transactor, + arg_movingFundsTx, + arg_movingFundsProof, + arg_mainUtxo, + arg_walletPubKeyHash, ) - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "depositParameters", - ) - } - return result, err } -func (b *Bridge) DepositParametersAtBlock( - blockNumber *big.Int, -) (depositParameters, error) { - var result depositParameters +// Transaction submission. +func (b *Bridge) SubmitRedemptionProof( + arg_redemptionTx abi.BitcoinTxInfo, + arg_redemptionProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, + arg_walletPubKeyHash [20]byte, - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "depositParameters", - &result, + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction submitRedemptionProof", + " params: ", + fmt.Sprint( + arg_redemptionTx, + arg_redemptionProof, + arg_mainUtxo, + arg_walletPubKeyHash, + ), ) - return result, err -} + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() -func (b *Bridge) Deposits( - arg_depositKey *big.Int, -) (abi.DepositDepositRequest, error) { - result, err := b.contract.Deposits( - b.callerOptions, - arg_depositKey, - ) + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "deposits", - arg_depositKey, + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) } - return result, err -} - -func (b *Bridge) DepositsAtBlock( - arg_depositKey *big.Int, - blockNumber *big.Int, -) (abi.DepositDepositRequest, error) { - var result abi.DepositDepositRequest - - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "deposits", - &result, - arg_depositKey, - ) + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } - return result, err -} + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) -func (b *Bridge) FraudChallenges( - arg_challengeKey *big.Int, -) (abi.FraudFraudChallenge, error) { - result, err := b.contract.FraudChallenges( - b.callerOptions, - arg_challengeKey, + transaction, err := b.contract.SubmitRedemptionProof( + transactorOptions, + arg_redemptionTx, + arg_redemptionProof, + arg_mainUtxo, + arg_walletPubKeyHash, ) - if err != nil { - return result, b.errorResolver.ResolveError( + return transaction, b.errorResolver.ResolveError( err, - b.callerOptions.From, + b.transactorOptions.From, nil, - "fraudChallenges", - arg_challengeKey, + "submitRedemptionProof", + arg_redemptionTx, + arg_redemptionProof, + arg_mainUtxo, + arg_walletPubKeyHash, ) } - return result, err -} - -func (b *Bridge) FraudChallengesAtBlock( - arg_challengeKey *big.Int, - blockNumber *big.Int, -) (abi.FraudFraudChallenge, error) { - var result abi.FraudFraudChallenge - - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "fraudChallenges", - &result, - arg_challengeKey, + bLogger.Infof( + "submitted transaction submitRedemptionProof with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), ) - return result, err -} + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } -type fraudParameters struct { - FraudChallengeDepositAmount *big.Int - FraudChallengeDefeatTimeout uint32 - FraudSlashingAmount *big.Int - FraudNotifierRewardMultiplier uint32 -} + transaction, err := b.contract.SubmitRedemptionProof( + newTransactorOptions, + arg_redemptionTx, + arg_redemptionProof, + arg_mainUtxo, + arg_walletPubKeyHash, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "submitRedemptionProof", + arg_redemptionTx, + arg_redemptionProof, + arg_mainUtxo, + arg_walletPubKeyHash, + ) + } -func (b *Bridge) FraudParameters() (fraudParameters, error) { - result, err := b.contract.FraudParameters( - b.callerOptions, + bLogger.Infof( + "submitted transaction submitRedemptionProof with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, ) - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "fraudParameters", - ) - } + b.nonceManager.IncrementNonce() - return result, err + return transaction, err } -func (b *Bridge) FraudParametersAtBlock( +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallSubmitRedemptionProof( + arg_redemptionTx abi.BitcoinTxInfo, + arg_redemptionProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, + arg_walletPubKeyHash [20]byte, blockNumber *big.Int, -) (fraudParameters, error) { - var result fraudParameters +) error { + var result interface{} = nil err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, + b.transactorOptions.From, + blockNumber, nil, b.contractABI, b.caller, b.errorResolver, b.contractAddress, - "fraudParameters", + "submitRedemptionProof", &result, + arg_redemptionTx, + arg_redemptionProof, + arg_mainUtxo, + arg_walletPubKeyHash, ) - return result, err + return err } -func (b *Bridge) GetRedemptionWatchtower() (common.Address, error) { - result, err := b.contract.GetRedemptionWatchtower( - b.callerOptions, - ) +func (b *Bridge) SubmitRedemptionProofGasEstimate( + arg_redemptionTx abi.BitcoinTxInfo, + arg_redemptionProof abi.BitcoinTxProof, + arg_mainUtxo abi.BitcoinTxUTXO, + arg_walletPubKeyHash [20]byte, +) (uint64, error) { + var result uint64 - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "getRedemptionWatchtower", - ) - } + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "submitRedemptionProof", + b.contractABI, + b.transactor, + arg_redemptionTx, + arg_redemptionProof, + arg_mainUtxo, + arg_walletPubKeyHash, + ) return result, err } -func (b *Bridge) GetRedemptionWatchtowerAtBlock( - blockNumber *big.Int, -) (common.Address, error) { - var result common.Address +// Transaction submission. +func (b *Bridge) TransferGovernance( + arg_newGovernance common.Address, - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "getRedemptionWatchtower", - &result, + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction transferGovernance", + " params: ", + fmt.Sprint( + arg_newGovernance, + ), ) - return result, err -} + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() -func (b *Bridge) Governance() (common.Address, error) { - result, err := b.contract.Governance( - b.callerOptions, - ) + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "governance", + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) } - return result, err -} - -func (b *Bridge) GovernanceAtBlock( - blockNumber *big.Int, -) (common.Address, error) { - var result common.Address - - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "governance", - &result, - ) + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } - return result, err -} + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) -func (b *Bridge) IsVaultTrusted( - arg_vault common.Address, -) (bool, error) { - result, err := b.contract.IsVaultTrusted( - b.callerOptions, - arg_vault, + transaction, err := b.contract.TransferGovernance( + transactorOptions, + arg_newGovernance, ) - if err != nil { - return result, b.errorResolver.ResolveError( + return transaction, b.errorResolver.ResolveError( err, - b.callerOptions.From, + b.transactorOptions.From, nil, - "isVaultTrusted", - arg_vault, + "transferGovernance", + arg_newGovernance, ) } - return result, err -} + bLogger.Infof( + "submitted transaction transferGovernance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) -func (b *Bridge) IsVaultTrustedAtBlock( - arg_vault common.Address, - blockNumber *big.Int, -) (bool, error) { - var result bool + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "isVaultTrusted", - &result, - arg_vault, - ) + transaction, err := b.contract.TransferGovernance( + newTransactorOptions, + arg_newGovernance, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "transferGovernance", + arg_newGovernance, + ) + } - return result, err -} + bLogger.Infof( + "submitted transaction transferGovernance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) -func (b *Bridge) LiveWalletsCount() (uint32, error) { - result, err := b.contract.LiveWalletsCount( - b.callerOptions, + return transaction, nil + }, ) - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "liveWalletsCount", - ) - } + b.nonceManager.IncrementNonce() - return result, err + return transaction, err } -func (b *Bridge) LiveWalletsCountAtBlock( +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallTransferGovernance( + arg_newGovernance common.Address, blockNumber *big.Int, -) (uint32, error) { - var result uint32 +) error { + var result interface{} = nil err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, + b.transactorOptions.From, + blockNumber, nil, b.contractABI, b.caller, b.errorResolver, b.contractAddress, - "liveWalletsCount", + "transferGovernance", &result, + arg_newGovernance, ) - return result, err -} - -func (b *Bridge) MovedFundsSweepRequests( - arg_requestKey *big.Int, -) (abi.MovingFundsMovedFundsSweepRequest, error) { - result, err := b.contract.MovedFundsSweepRequests( - b.callerOptions, - arg_requestKey, - ) - - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "movedFundsSweepRequests", - arg_requestKey, - ) - } - - return result, err + return err } -func (b *Bridge) MovedFundsSweepRequestsAtBlock( - arg_requestKey *big.Int, - blockNumber *big.Int, -) (abi.MovingFundsMovedFundsSweepRequest, error) { - var result abi.MovingFundsMovedFundsSweepRequest +func (b *Bridge) TransferGovernanceGasEstimate( + arg_newGovernance common.Address, +) (uint64, error) { + var result uint64 - err := chainutil.CallAtBlock( + result, err := chainutil.EstimateGas( b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, b.contractAddress, - "movedFundsSweepRequests", - &result, - arg_requestKey, + "transferGovernance", + b.contractABI, + b.transactor, + arg_newGovernance, ) return result, err } -type movingFundsParameters struct { - MovingFundsTxMaxTotalFee uint64 - MovingFundsDustThreshold uint64 - MovingFundsTimeoutResetDelay uint32 - MovingFundsTimeout uint32 - MovingFundsTimeoutSlashingAmount *big.Int - MovingFundsTimeoutNotifierRewardMultiplier uint32 - MovingFundsCommitmentGasOffset uint16 - MovedFundsSweepTxMaxTotalFee uint64 - MovedFundsSweepTimeout uint32 - MovedFundsSweepTimeoutSlashingAmount *big.Int - MovedFundsSweepTimeoutNotifierRewardMultiplier uint32 -} - -func (b *Bridge) MovingFundsParameters() (movingFundsParameters, error) { - result, err := b.contract.MovingFundsParameters( - b.callerOptions, - ) +// Transaction submission. +func (b *Bridge) UpdateDepositParameters( + arg_depositDustThreshold uint64, + arg_depositTreasuryFeeDivisor uint64, + arg_depositTxMaxFee uint64, + arg_depositRevealAheadPeriod uint32, - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "movingFundsParameters", - ) - } - - return result, err -} - -func (b *Bridge) MovingFundsParametersAtBlock( - blockNumber *big.Int, -) (movingFundsParameters, error) { - var result movingFundsParameters - - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "movingFundsParameters", - &result, + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction updateDepositParameters", + " params: ", + fmt.Sprint( + arg_depositDustThreshold, + arg_depositTreasuryFeeDivisor, + arg_depositTxMaxFee, + arg_depositRevealAheadPeriod, + ), ) - return result, err -} + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() -func (b *Bridge) PendingRedemptions( - arg_redemptionKey *big.Int, -) (abi.RedemptionRedemptionRequest, error) { - result, err := b.contract.PendingRedemptions( - b.callerOptions, - arg_redemptionKey, - ) + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "pendingRedemptions", - arg_redemptionKey, + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) } - return result, err -} - -func (b *Bridge) PendingRedemptionsAtBlock( - arg_redemptionKey *big.Int, - blockNumber *big.Int, -) (abi.RedemptionRedemptionRequest, error) { - var result abi.RedemptionRedemptionRequest - - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "pendingRedemptions", - &result, - arg_redemptionKey, - ) - - return result, err -} + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } -type redemptionParameters struct { - RedemptionDustThreshold uint64 - RedemptionTreasuryFeeDivisor uint64 - RedemptionTxMaxFee uint64 - RedemptionTxMaxTotalFee uint64 - RedemptionTimeout uint32 - RedemptionTimeoutSlashingAmount *big.Int - RedemptionTimeoutNotifierRewardMultiplier uint32 -} + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) -func (b *Bridge) RedemptionParameters() (redemptionParameters, error) { - result, err := b.contract.RedemptionParameters( - b.callerOptions, + transaction, err := b.contract.UpdateDepositParameters( + transactorOptions, + arg_depositDustThreshold, + arg_depositTreasuryFeeDivisor, + arg_depositTxMaxFee, + arg_depositRevealAheadPeriod, ) - if err != nil { - return result, b.errorResolver.ResolveError( + return transaction, b.errorResolver.ResolveError( err, - b.callerOptions.From, + b.transactorOptions.From, nil, - "redemptionParameters", + "updateDepositParameters", + arg_depositDustThreshold, + arg_depositTreasuryFeeDivisor, + arg_depositTxMaxFee, + arg_depositRevealAheadPeriod, ) } - return result, err -} + bLogger.Infof( + "submitted transaction updateDepositParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) -func (b *Bridge) RedemptionParametersAtBlock( - blockNumber *big.Int, -) (redemptionParameters, error) { - var result redemptionParameters + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "redemptionParameters", - &result, - ) + transaction, err := b.contract.UpdateDepositParameters( + newTransactorOptions, + arg_depositDustThreshold, + arg_depositTreasuryFeeDivisor, + arg_depositTxMaxFee, + arg_depositRevealAheadPeriod, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateDepositParameters", + arg_depositDustThreshold, + arg_depositTreasuryFeeDivisor, + arg_depositTxMaxFee, + arg_depositRevealAheadPeriod, + ) + } - return result, err -} + bLogger.Infof( + "submitted transaction updateDepositParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) -func (b *Bridge) SpentMainUTXOs( - arg_utxoKey *big.Int, -) (bool, error) { - result, err := b.contract.SpentMainUTXOs( - b.callerOptions, - arg_utxoKey, + return transaction, nil + }, ) - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "spentMainUTXOs", - arg_utxoKey, - ) - } + b.nonceManager.IncrementNonce() - return result, err + return transaction, err } -func (b *Bridge) SpentMainUTXOsAtBlock( - arg_utxoKey *big.Int, +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallUpdateDepositParameters( + arg_depositDustThreshold uint64, + arg_depositTreasuryFeeDivisor uint64, + arg_depositTxMaxFee uint64, + arg_depositRevealAheadPeriod uint32, blockNumber *big.Int, -) (bool, error) { - var result bool +) error { + var result interface{} = nil err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, + b.transactorOptions.From, + blockNumber, nil, b.contractABI, b.caller, b.errorResolver, b.contractAddress, - "spentMainUTXOs", + "updateDepositParameters", &result, - arg_utxoKey, + arg_depositDustThreshold, + arg_depositTreasuryFeeDivisor, + arg_depositTxMaxFee, + arg_depositRevealAheadPeriod, ) - return result, err + return err } -func (b *Bridge) TimedOutRedemptions( - arg_redemptionKey *big.Int, -) (abi.RedemptionRedemptionRequest, error) { - result, err := b.contract.TimedOutRedemptions( - b.callerOptions, - arg_redemptionKey, - ) +func (b *Bridge) UpdateDepositParametersGasEstimate( + arg_depositDustThreshold uint64, + arg_depositTreasuryFeeDivisor uint64, + arg_depositTxMaxFee uint64, + arg_depositRevealAheadPeriod uint32, +) (uint64, error) { + var result uint64 - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "timedOutRedemptions", - arg_redemptionKey, - ) - } + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "updateDepositParameters", + b.contractABI, + b.transactor, + arg_depositDustThreshold, + arg_depositTreasuryFeeDivisor, + arg_depositTxMaxFee, + arg_depositRevealAheadPeriod, + ) return result, err } -func (b *Bridge) TimedOutRedemptionsAtBlock( - arg_redemptionKey *big.Int, +// Transaction submission. +func (b *Bridge) UpdateFraudParameters( + arg_fraudChallengeDepositAmount *big.Int, + arg_fraudChallengeDefeatTimeout uint32, + arg_fraudSlashingAmount *big.Int, + arg_fraudNotifierRewardMultiplier uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction updateFraudParameters", + " params: ", + fmt.Sprint( + arg_fraudChallengeDepositAmount, + arg_fraudChallengeDefeatTimeout, + arg_fraudSlashingAmount, + arg_fraudNotifierRewardMultiplier, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.UpdateFraudParameters( + transactorOptions, + arg_fraudChallengeDepositAmount, + arg_fraudChallengeDefeatTimeout, + arg_fraudSlashingAmount, + arg_fraudNotifierRewardMultiplier, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateFraudParameters", + arg_fraudChallengeDepositAmount, + arg_fraudChallengeDefeatTimeout, + arg_fraudSlashingAmount, + arg_fraudNotifierRewardMultiplier, + ) + } + + bLogger.Infof( + "submitted transaction updateFraudParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.UpdateFraudParameters( + newTransactorOptions, + arg_fraudChallengeDepositAmount, + arg_fraudChallengeDefeatTimeout, + arg_fraudSlashingAmount, + arg_fraudNotifierRewardMultiplier, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateFraudParameters", + arg_fraudChallengeDepositAmount, + arg_fraudChallengeDefeatTimeout, + arg_fraudSlashingAmount, + arg_fraudNotifierRewardMultiplier, + ) + } + + bLogger.Infof( + "submitted transaction updateFraudParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallUpdateFraudParameters( + arg_fraudChallengeDepositAmount *big.Int, + arg_fraudChallengeDefeatTimeout uint32, + arg_fraudSlashingAmount *big.Int, + arg_fraudNotifierRewardMultiplier uint32, blockNumber *big.Int, -) (abi.RedemptionRedemptionRequest, error) { - var result abi.RedemptionRedemptionRequest +) error { + var result interface{} = nil err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, + b.transactorOptions.From, + blockNumber, nil, b.contractABI, b.caller, b.errorResolver, b.contractAddress, - "timedOutRedemptions", + "updateFraudParameters", &result, - arg_redemptionKey, + arg_fraudChallengeDepositAmount, + arg_fraudChallengeDefeatTimeout, + arg_fraudSlashingAmount, + arg_fraudNotifierRewardMultiplier, + ) + + return err +} + +func (b *Bridge) UpdateFraudParametersGasEstimate( + arg_fraudChallengeDepositAmount *big.Int, + arg_fraudChallengeDefeatTimeout uint32, + arg_fraudSlashingAmount *big.Int, + arg_fraudNotifierRewardMultiplier uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "updateFraudParameters", + b.contractABI, + b.transactor, + arg_fraudChallengeDepositAmount, + arg_fraudChallengeDefeatTimeout, + arg_fraudSlashingAmount, + arg_fraudNotifierRewardMultiplier, ) return result, err } -func (b *Bridge) Treasury() (common.Address, error) { - result, err := b.contract.Treasury( - b.callerOptions, +// Transaction submission. +func (b *Bridge) UpdateMovingFundsParameters( + arg_movingFundsTxMaxTotalFee uint64, + arg_movingFundsDustThreshold uint64, + arg_movingFundsTimeoutResetDelay uint32, + arg_movingFundsTimeout uint32, + arg_movingFundsTimeoutSlashingAmount *big.Int, + arg_movingFundsTimeoutNotifierRewardMultiplier uint32, + arg_movingFundsCommitmentGasOffset uint16, + arg_movedFundsSweepTxMaxTotalFee uint64, + arg_movedFundsSweepTimeout uint32, + arg_movedFundsSweepTimeoutSlashingAmount *big.Int, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction updateMovingFundsParameters", + " params: ", + fmt.Sprint( + arg_movingFundsTxMaxTotalFee, + arg_movingFundsDustThreshold, + arg_movingFundsTimeoutResetDelay, + arg_movingFundsTimeout, + arg_movingFundsTimeoutSlashingAmount, + arg_movingFundsTimeoutNotifierRewardMultiplier, + arg_movingFundsCommitmentGasOffset, + arg_movedFundsSweepTxMaxTotalFee, + arg_movedFundsSweepTimeout, + arg_movedFundsSweepTimeoutSlashingAmount, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.UpdateMovingFundsParameters( + transactorOptions, + arg_movingFundsTxMaxTotalFee, + arg_movingFundsDustThreshold, + arg_movingFundsTimeoutResetDelay, + arg_movingFundsTimeout, + arg_movingFundsTimeoutSlashingAmount, + arg_movingFundsTimeoutNotifierRewardMultiplier, + arg_movingFundsCommitmentGasOffset, + arg_movedFundsSweepTxMaxTotalFee, + arg_movedFundsSweepTimeout, + arg_movedFundsSweepTimeoutSlashingAmount, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateMovingFundsParameters", + arg_movingFundsTxMaxTotalFee, + arg_movingFundsDustThreshold, + arg_movingFundsTimeoutResetDelay, + arg_movingFundsTimeout, + arg_movingFundsTimeoutSlashingAmount, + arg_movingFundsTimeoutNotifierRewardMultiplier, + arg_movingFundsCommitmentGasOffset, + arg_movedFundsSweepTxMaxTotalFee, + arg_movedFundsSweepTimeout, + arg_movedFundsSweepTimeoutSlashingAmount, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + ) + } + + bLogger.Infof( + "submitted transaction updateMovingFundsParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.UpdateMovingFundsParameters( + newTransactorOptions, + arg_movingFundsTxMaxTotalFee, + arg_movingFundsDustThreshold, + arg_movingFundsTimeoutResetDelay, + arg_movingFundsTimeout, + arg_movingFundsTimeoutSlashingAmount, + arg_movingFundsTimeoutNotifierRewardMultiplier, + arg_movingFundsCommitmentGasOffset, + arg_movedFundsSweepTxMaxTotalFee, + arg_movedFundsSweepTimeout, + arg_movedFundsSweepTimeoutSlashingAmount, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateMovingFundsParameters", + arg_movingFundsTxMaxTotalFee, + arg_movingFundsDustThreshold, + arg_movingFundsTimeoutResetDelay, + arg_movingFundsTimeout, + arg_movingFundsTimeoutSlashingAmount, + arg_movingFundsTimeoutNotifierRewardMultiplier, + arg_movingFundsCommitmentGasOffset, + arg_movedFundsSweepTxMaxTotalFee, + arg_movedFundsSweepTimeout, + arg_movedFundsSweepTimeoutSlashingAmount, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + ) + } + + bLogger.Infof( + "submitted transaction updateMovingFundsParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallUpdateMovingFundsParameters( + arg_movingFundsTxMaxTotalFee uint64, + arg_movingFundsDustThreshold uint64, + arg_movingFundsTimeoutResetDelay uint32, + arg_movingFundsTimeout uint32, + arg_movingFundsTimeoutSlashingAmount *big.Int, + arg_movingFundsTimeoutNotifierRewardMultiplier uint32, + arg_movingFundsCommitmentGasOffset uint16, + arg_movedFundsSweepTxMaxTotalFee uint64, + arg_movedFundsSweepTimeout uint32, + arg_movedFundsSweepTimeoutSlashingAmount *big.Int, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier uint32, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "updateMovingFundsParameters", + &result, + arg_movingFundsTxMaxTotalFee, + arg_movingFundsDustThreshold, + arg_movingFundsTimeoutResetDelay, + arg_movingFundsTimeout, + arg_movingFundsTimeoutSlashingAmount, + arg_movingFundsTimeoutNotifierRewardMultiplier, + arg_movingFundsCommitmentGasOffset, + arg_movedFundsSweepTxMaxTotalFee, + arg_movedFundsSweepTimeout, + arg_movedFundsSweepTimeoutSlashingAmount, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + ) + + return err +} + +func (b *Bridge) UpdateMovingFundsParametersGasEstimate( + arg_movingFundsTxMaxTotalFee uint64, + arg_movingFundsDustThreshold uint64, + arg_movingFundsTimeoutResetDelay uint32, + arg_movingFundsTimeout uint32, + arg_movingFundsTimeoutSlashingAmount *big.Int, + arg_movingFundsTimeoutNotifierRewardMultiplier uint32, + arg_movingFundsCommitmentGasOffset uint16, + arg_movedFundsSweepTxMaxTotalFee uint64, + arg_movedFundsSweepTimeout uint32, + arg_movedFundsSweepTimeoutSlashingAmount *big.Int, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "updateMovingFundsParameters", + b.contractABI, + b.transactor, + arg_movingFundsTxMaxTotalFee, + arg_movingFundsDustThreshold, + arg_movingFundsTimeoutResetDelay, + arg_movingFundsTimeout, + arg_movingFundsTimeoutSlashingAmount, + arg_movingFundsTimeoutNotifierRewardMultiplier, + arg_movingFundsCommitmentGasOffset, + arg_movedFundsSweepTxMaxTotalFee, + arg_movedFundsSweepTimeout, + arg_movedFundsSweepTimeoutSlashingAmount, + arg_movedFundsSweepTimeoutNotifierRewardMultiplier, + ) + + return result, err +} + +// Transaction submission. +func (b *Bridge) UpdateRedemptionParameters( + arg_redemptionDustThreshold uint64, + arg_redemptionTreasuryFeeDivisor uint64, + arg_redemptionTxMaxFee uint64, + arg_redemptionTxMaxTotalFee uint64, + arg_redemptionTimeout uint32, + arg_redemptionTimeoutSlashingAmount *big.Int, + arg_redemptionTimeoutNotifierRewardMultiplier uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction updateRedemptionParameters", + " params: ", + fmt.Sprint( + arg_redemptionDustThreshold, + arg_redemptionTreasuryFeeDivisor, + arg_redemptionTxMaxFee, + arg_redemptionTxMaxTotalFee, + arg_redemptionTimeout, + arg_redemptionTimeoutSlashingAmount, + arg_redemptionTimeoutNotifierRewardMultiplier, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.UpdateRedemptionParameters( + transactorOptions, + arg_redemptionDustThreshold, + arg_redemptionTreasuryFeeDivisor, + arg_redemptionTxMaxFee, + arg_redemptionTxMaxTotalFee, + arg_redemptionTimeout, + arg_redemptionTimeoutSlashingAmount, + arg_redemptionTimeoutNotifierRewardMultiplier, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateRedemptionParameters", + arg_redemptionDustThreshold, + arg_redemptionTreasuryFeeDivisor, + arg_redemptionTxMaxFee, + arg_redemptionTxMaxTotalFee, + arg_redemptionTimeout, + arg_redemptionTimeoutSlashingAmount, + arg_redemptionTimeoutNotifierRewardMultiplier, + ) + } + + bLogger.Infof( + "submitted transaction updateRedemptionParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.UpdateRedemptionParameters( + newTransactorOptions, + arg_redemptionDustThreshold, + arg_redemptionTreasuryFeeDivisor, + arg_redemptionTxMaxFee, + arg_redemptionTxMaxTotalFee, + arg_redemptionTimeout, + arg_redemptionTimeoutSlashingAmount, + arg_redemptionTimeoutNotifierRewardMultiplier, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateRedemptionParameters", + arg_redemptionDustThreshold, + arg_redemptionTreasuryFeeDivisor, + arg_redemptionTxMaxFee, + arg_redemptionTxMaxTotalFee, + arg_redemptionTimeout, + arg_redemptionTimeoutSlashingAmount, + arg_redemptionTimeoutNotifierRewardMultiplier, + ) + } + + bLogger.Infof( + "submitted transaction updateRedemptionParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallUpdateRedemptionParameters( + arg_redemptionDustThreshold uint64, + arg_redemptionTreasuryFeeDivisor uint64, + arg_redemptionTxMaxFee uint64, + arg_redemptionTxMaxTotalFee uint64, + arg_redemptionTimeout uint32, + arg_redemptionTimeoutSlashingAmount *big.Int, + arg_redemptionTimeoutNotifierRewardMultiplier uint32, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "updateRedemptionParameters", + &result, + arg_redemptionDustThreshold, + arg_redemptionTreasuryFeeDivisor, + arg_redemptionTxMaxFee, + arg_redemptionTxMaxTotalFee, + arg_redemptionTimeout, + arg_redemptionTimeoutSlashingAmount, + arg_redemptionTimeoutNotifierRewardMultiplier, + ) + + return err +} + +func (b *Bridge) UpdateRedemptionParametersGasEstimate( + arg_redemptionDustThreshold uint64, + arg_redemptionTreasuryFeeDivisor uint64, + arg_redemptionTxMaxFee uint64, + arg_redemptionTxMaxTotalFee uint64, + arg_redemptionTimeout uint32, + arg_redemptionTimeoutSlashingAmount *big.Int, + arg_redemptionTimeoutNotifierRewardMultiplier uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "updateRedemptionParameters", + b.contractABI, + b.transactor, + arg_redemptionDustThreshold, + arg_redemptionTreasuryFeeDivisor, + arg_redemptionTxMaxFee, + arg_redemptionTxMaxTotalFee, + arg_redemptionTimeout, + arg_redemptionTimeoutSlashingAmount, + arg_redemptionTimeoutNotifierRewardMultiplier, + ) + + return result, err +} + +// Transaction submission. +func (b *Bridge) UpdateTreasury( + arg_treasury common.Address, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction updateTreasury", + " params: ", + fmt.Sprint( + arg_treasury, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.UpdateTreasury( + transactorOptions, + arg_treasury, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateTreasury", + arg_treasury, + ) + } + + bLogger.Infof( + "submitted transaction updateTreasury with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.UpdateTreasury( + newTransactorOptions, + arg_treasury, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateTreasury", + arg_treasury, + ) + } + + bLogger.Infof( + "submitted transaction updateTreasury with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallUpdateTreasury( + arg_treasury common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "updateTreasury", + &result, + arg_treasury, + ) + + return err +} + +func (b *Bridge) UpdateTreasuryGasEstimate( + arg_treasury common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "updateTreasury", + b.contractABI, + b.transactor, + arg_treasury, + ) + + return result, err +} + +// Transaction submission. +func (b *Bridge) UpdateWalletParameters( + arg_walletCreationPeriod uint32, + arg_walletCreationMinBtcBalance uint64, + arg_walletCreationMaxBtcBalance uint64, + arg_walletClosureMinBtcBalance uint64, + arg_walletMaxAge uint32, + arg_walletMaxBtcTransfer uint64, + arg_walletClosingPeriod uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction updateWalletParameters", + " params: ", + fmt.Sprint( + arg_walletCreationPeriod, + arg_walletCreationMinBtcBalance, + arg_walletCreationMaxBtcBalance, + arg_walletClosureMinBtcBalance, + arg_walletMaxAge, + arg_walletMaxBtcTransfer, + arg_walletClosingPeriod, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.UpdateWalletParameters( + transactorOptions, + arg_walletCreationPeriod, + arg_walletCreationMinBtcBalance, + arg_walletCreationMaxBtcBalance, + arg_walletClosureMinBtcBalance, + arg_walletMaxAge, + arg_walletMaxBtcTransfer, + arg_walletClosingPeriod, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateWalletParameters", + arg_walletCreationPeriod, + arg_walletCreationMinBtcBalance, + arg_walletCreationMaxBtcBalance, + arg_walletClosureMinBtcBalance, + arg_walletMaxAge, + arg_walletMaxBtcTransfer, + arg_walletClosingPeriod, + ) + } + + bLogger.Infof( + "submitted transaction updateWalletParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.UpdateWalletParameters( + newTransactorOptions, + arg_walletCreationPeriod, + arg_walletCreationMinBtcBalance, + arg_walletCreationMaxBtcBalance, + arg_walletClosureMinBtcBalance, + arg_walletMaxAge, + arg_walletMaxBtcTransfer, + arg_walletClosingPeriod, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "updateWalletParameters", + arg_walletCreationPeriod, + arg_walletCreationMinBtcBalance, + arg_walletCreationMaxBtcBalance, + arg_walletClosureMinBtcBalance, + arg_walletMaxAge, + arg_walletMaxBtcTransfer, + arg_walletClosingPeriod, + ) + } + + bLogger.Infof( + "submitted transaction updateWalletParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallUpdateWalletParameters( + arg_walletCreationPeriod uint32, + arg_walletCreationMinBtcBalance uint64, + arg_walletCreationMaxBtcBalance uint64, + arg_walletClosureMinBtcBalance uint64, + arg_walletMaxAge uint32, + arg_walletMaxBtcTransfer uint64, + arg_walletClosingPeriod uint32, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "updateWalletParameters", + &result, + arg_walletCreationPeriod, + arg_walletCreationMinBtcBalance, + arg_walletCreationMaxBtcBalance, + arg_walletClosureMinBtcBalance, + arg_walletMaxAge, + arg_walletMaxBtcTransfer, + arg_walletClosingPeriod, + ) + + return err +} + +func (b *Bridge) UpdateWalletParametersGasEstimate( + arg_walletCreationPeriod uint32, + arg_walletCreationMinBtcBalance uint64, + arg_walletCreationMaxBtcBalance uint64, + arg_walletClosureMinBtcBalance uint64, + arg_walletMaxAge uint32, + arg_walletMaxBtcTransfer uint64, + arg_walletClosingPeriod uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "updateWalletParameters", + b.contractABI, + b.transactor, + arg_walletCreationPeriod, + arg_walletCreationMinBtcBalance, + arg_walletCreationMaxBtcBalance, + arg_walletClosureMinBtcBalance, + arg_walletMaxAge, + arg_walletMaxBtcTransfer, + arg_walletClosingPeriod, + ) + + return result, err +} + +// ----- Const Methods ------ + +func (b *Bridge) ActiveWalletID() ([32]byte, error) { + result, err := b.contract.ActiveWalletID( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "activeWalletID", + ) + } + + return result, err +} + +func (b *Bridge) ActiveWalletIDAtBlock( + blockNumber *big.Int, +) ([32]byte, error) { + var result [32]byte + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "activeWalletID", + &result, + ) + + return result, err +} + +func (b *Bridge) ActiveWalletPubKeyHash() ([20]byte, error) { + result, err := b.contract.ActiveWalletPubKeyHash( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "activeWalletPubKeyHash", + ) + } + + return result, err +} + +func (b *Bridge) ActiveWalletPubKeyHashAtBlock( + blockNumber *big.Int, +) ([20]byte, error) { + var result [20]byte + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "activeWalletPubKeyHash", + &result, + ) + + return result, err +} + +type contractReferences struct { + Bank common.Address + Relay common.Address + EcdsaWalletRegistry common.Address + ReimbursementPool common.Address +} + +func (b *Bridge) ContractReferences() (contractReferences, error) { + result, err := b.contract.ContractReferences( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "contractReferences", + ) + } + + return result, err +} + +func (b *Bridge) ContractReferencesAtBlock( + blockNumber *big.Int, +) (contractReferences, error) { + var result contractReferences + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "contractReferences", + &result, + ) + + return result, err +} + +type depositParameters struct { + DepositDustThreshold uint64 + DepositTreasuryFeeDivisor uint64 + DepositTxMaxFee uint64 + DepositRevealAheadPeriod uint32 +} + +func (b *Bridge) DepositParameters() (depositParameters, error) { + result, err := b.contract.DepositParameters( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "depositParameters", + ) + } + + return result, err +} + +func (b *Bridge) DepositParametersAtBlock( + blockNumber *big.Int, +) (depositParameters, error) { + var result depositParameters + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "depositParameters", + &result, + ) + + return result, err +} + +func (b *Bridge) Deposits( + arg_depositKey *big.Int, +) (abi.DepositDepositRequest, error) { + result, err := b.contract.Deposits( + b.callerOptions, + arg_depositKey, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "deposits", + arg_depositKey, + ) + } + + return result, err +} + +func (b *Bridge) DepositsAtBlock( + arg_depositKey *big.Int, + blockNumber *big.Int, +) (abi.DepositDepositRequest, error) { + var result abi.DepositDepositRequest + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "deposits", + &result, + arg_depositKey, + ) + + return result, err +} + +func (b *Bridge) EcdsaFraudRouter() (common.Address, error) { + result, err := b.contract.EcdsaFraudRouter( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "ecdsaFraudRouter", + ) + } + + return result, err +} + +func (b *Bridge) EcdsaFraudRouterAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "ecdsaFraudRouter", + &result, + ) + + return result, err +} + +func (b *Bridge) EcdsaRetired() (bool, error) { + result, err := b.contract.EcdsaRetired( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "ecdsaRetired", + ) + } + + return result, err +} + +func (b *Bridge) EcdsaRetiredAtBlock( + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "ecdsaRetired", + &result, + ) + + return result, err +} + +type fraudParameters struct { + FraudChallengeDepositAmount *big.Int + FraudChallengeDefeatTimeout uint32 + FraudSlashingAmount *big.Int + FraudNotifierRewardMultiplier uint32 +} + +func (b *Bridge) FraudParameters() (fraudParameters, error) { + result, err := b.contract.FraudParameters( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "fraudParameters", + ) + } + + return result, err +} + +func (b *Bridge) FraudParametersAtBlock( + blockNumber *big.Int, +) (fraudParameters, error) { + var result fraudParameters + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "fraudParameters", + &result, + ) + + return result, err +} + +type frostLifecycleContext struct { + FrostRegistry common.Address + WalletID [32]byte +} + +func (b *Bridge) FrostLifecycleContext( + arg_walletPubKeyHash [20]byte, +) (frostLifecycleContext, error) { + result, err := b.contract.FrostLifecycleContext( + b.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "frostLifecycleContext", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (b *Bridge) FrostLifecycleContextAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) (frostLifecycleContext, error) { + var result frostLifecycleContext + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "frostLifecycleContext", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + +func (b *Bridge) GetRebateStaking() (common.Address, error) { + result, err := b.contract.GetRebateStaking( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "getRebateStaking", + ) + } + + return result, err +} + +func (b *Bridge) GetRebateStakingAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "getRebateStaking", + &result, + ) + + return result, err +} + +func (b *Bridge) GetRedemptionWatchtower() (common.Address, error) { + result, err := b.contract.GetRedemptionWatchtower( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "getRedemptionWatchtower", + ) + } + + return result, err +} + +func (b *Bridge) GetRedemptionWatchtowerAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "getRedemptionWatchtower", + &result, + ) + + return result, err +} + +func (b *Bridge) Governance() (common.Address, error) { + result, err := b.contract.Governance( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "governance", + ) + } + + return result, err +} + +func (b *Bridge) GovernanceAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "governance", + &result, + ) + + return result, err +} + +func (b *Bridge) IsVaultTrusted( + arg_vault common.Address, +) (bool, error) { + result, err := b.contract.IsVaultTrusted( + b.callerOptions, + arg_vault, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "isVaultTrusted", + arg_vault, + ) + } + + return result, err +} + +func (b *Bridge) IsVaultTrustedAtBlock( + arg_vault common.Address, + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "isVaultTrusted", + &result, + arg_vault, + ) + + return result, err +} + +func (b *Bridge) LiveWalletsCount() (uint32, error) { + result, err := b.contract.LiveWalletsCount( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "liveWalletsCount", + ) + } + + return result, err +} + +func (b *Bridge) LiveWalletsCountAtBlock( + blockNumber *big.Int, +) (uint32, error) { + var result uint32 + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "liveWalletsCount", + &result, + ) + + return result, err +} + +func (b *Bridge) MovedFundsSweepRequests( + arg_requestKey *big.Int, +) (abi.MovingFundsMovedFundsSweepRequest, error) { + result, err := b.contract.MovedFundsSweepRequests( + b.callerOptions, + arg_requestKey, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "movedFundsSweepRequests", + arg_requestKey, + ) + } + + return result, err +} + +func (b *Bridge) MovedFundsSweepRequestsAtBlock( + arg_requestKey *big.Int, + blockNumber *big.Int, +) (abi.MovingFundsMovedFundsSweepRequest, error) { + var result abi.MovingFundsMovedFundsSweepRequest + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "movedFundsSweepRequests", + &result, + arg_requestKey, + ) + + return result, err +} + +type movingFundsParameters struct { + MovingFundsTxMaxTotalFee uint64 + MovingFundsDustThreshold uint64 + MovingFundsTimeoutResetDelay uint32 + MovingFundsTimeout uint32 + MovingFundsTimeoutSlashingAmount *big.Int + MovingFundsTimeoutNotifierRewardMultiplier uint32 + MovingFundsCommitmentGasOffset uint16 + MovedFundsSweepTxMaxTotalFee uint64 + MovedFundsSweepTimeout uint32 + MovedFundsSweepTimeoutSlashingAmount *big.Int + MovedFundsSweepTimeoutNotifierRewardMultiplier uint32 +} + +func (b *Bridge) MovingFundsParameters() (movingFundsParameters, error) { + result, err := b.contract.MovingFundsParameters( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "movingFundsParameters", + ) + } + + return result, err +} + +func (b *Bridge) MovingFundsParametersAtBlock( + blockNumber *big.Int, +) (movingFundsParameters, error) { + var result movingFundsParameters + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "movingFundsParameters", + &result, + ) + + return result, err +} + +func (b *Bridge) P2trFraudRouter() (common.Address, error) { + result, err := b.contract.P2trFraudRouter( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "p2trFraudRouter", + ) + } + + return result, err +} + +func (b *Bridge) P2trFraudRouterAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "p2trFraudRouter", + &result, + ) + + return result, err +} + +func (b *Bridge) PendingRedemptions( + arg_redemptionKey *big.Int, +) (abi.RedemptionRedemptionRequest, error) { + result, err := b.contract.PendingRedemptions( + b.callerOptions, + arg_redemptionKey, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "pendingRedemptions", + arg_redemptionKey, + ) + } + + return result, err +} + +func (b *Bridge) PendingRedemptionsAtBlock( + arg_redemptionKey *big.Int, + blockNumber *big.Int, +) (abi.RedemptionRedemptionRequest, error) { + var result abi.RedemptionRedemptionRequest + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "pendingRedemptions", + &result, + arg_redemptionKey, + ) + + return result, err +} + +type redemptionParameters struct { + RedemptionDustThreshold uint64 + RedemptionTreasuryFeeDivisor uint64 + RedemptionTxMaxFee uint64 + RedemptionTxMaxTotalFee uint64 + RedemptionTimeout uint32 + RedemptionTimeoutSlashingAmount *big.Int + RedemptionTimeoutNotifierRewardMultiplier uint32 +} + +func (b *Bridge) RedemptionParameters() (redemptionParameters, error) { + result, err := b.contract.RedemptionParameters( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "redemptionParameters", + ) + } + + return result, err +} + +func (b *Bridge) RedemptionParametersAtBlock( + blockNumber *big.Int, +) (redemptionParameters, error) { + var result redemptionParameters + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "redemptionParameters", + &result, + ) + + return result, err +} + +func (b *Bridge) SpentMainUTXOs( + arg_utxoKey *big.Int, +) (bool, error) { + result, err := b.contract.SpentMainUTXOs( + b.callerOptions, + arg_utxoKey, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "spentMainUTXOs", + arg_utxoKey, + ) + } + + return result, err +} + +func (b *Bridge) SpentMainUTXOsAtBlock( + arg_utxoKey *big.Int, + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "spentMainUTXOs", + &result, + arg_utxoKey, + ) + + return result, err +} + +func (b *Bridge) TimedOutRedemptions( + arg_redemptionKey *big.Int, +) (abi.RedemptionRedemptionRequest, error) { + result, err := b.contract.TimedOutRedemptions( + b.callerOptions, + arg_redemptionKey, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "timedOutRedemptions", + arg_redemptionKey, + ) + } + + return result, err +} + +func (b *Bridge) TimedOutRedemptionsAtBlock( + arg_redemptionKey *big.Int, + blockNumber *big.Int, +) (abi.RedemptionRedemptionRequest, error) { + var result abi.RedemptionRedemptionRequest + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "timedOutRedemptions", + &result, + arg_redemptionKey, + ) + + return result, err +} + +func (b *Bridge) Treasury() (common.Address, error) { + result, err := b.contract.Treasury( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "treasury", + ) + } + + return result, err +} + +func (b *Bridge) TreasuryAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "treasury", + &result, + ) + + return result, err +} + +func (b *Bridge) TxProofDifficultyFactor() (*big.Int, error) { + result, err := b.contract.TxProofDifficultyFactor( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "txProofDifficultyFactor", + ) + } + + return result, err +} + +func (b *Bridge) TxProofDifficultyFactorAtBlock( + blockNumber *big.Int, +) (*big.Int, error) { + var result *big.Int + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "txProofDifficultyFactor", + &result, + ) + + return result, err +} + +func (b *Bridge) WalletID( + arg_walletPubKeyHash [20]byte, +) ([32]byte, error) { + result, err := b.contract.WalletID( + b.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "walletID", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (b *Bridge) WalletIDAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) ([32]byte, error) { + var result [32]byte + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "walletID", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + +type walletParameters struct { + WalletCreationPeriod uint32 + WalletCreationMinBtcBalance uint64 + WalletCreationMaxBtcBalance uint64 + WalletClosureMinBtcBalance uint64 + WalletMaxAge uint32 + WalletMaxBtcTransfer uint64 + WalletClosingPeriod uint32 +} + +func (b *Bridge) WalletParameters() (walletParameters, error) { + result, err := b.contract.WalletParameters( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "walletParameters", + ) + } + + return result, err +} + +func (b *Bridge) WalletParametersAtBlock( + blockNumber *big.Int, +) (walletParameters, error) { + var result walletParameters + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "walletParameters", + &result, + ) + + return result, err +} + +func (b *Bridge) WalletPubKeyHashForWalletID( + arg_walletId [32]byte, +) ([20]byte, error) { + result, err := b.contract.WalletPubKeyHashForWalletID( + b.callerOptions, + arg_walletId, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "walletPubKeyHashForWalletID", + arg_walletId, + ) + } + + return result, err +} + +func (b *Bridge) WalletPubKeyHashForWalletIDAtBlock( + arg_walletId [32]byte, + blockNumber *big.Int, +) ([20]byte, error) { + var result [20]byte + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "walletPubKeyHashForWalletID", + &result, + arg_walletId, + ) + + return result, err +} + +func (b *Bridge) Wallets( + arg_walletPubKeyHash [20]byte, +) (abi.WalletsWallet, error) { + result, err := b.contract.Wallets( + b.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "wallets", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (b *Bridge) WalletsAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) (abi.WalletsWallet, error) { + var result abi.WalletsWallet + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "wallets", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + +func (b *Bridge) WalletsByWalletID( + arg_walletId [32]byte, +) (abi.WalletsWallet, error) { + result, err := b.contract.WalletsByWalletID( + b.callerOptions, + arg_walletId, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "walletsByWalletID", + arg_walletId, + ) + } + + return result, err +} + +func (b *Bridge) WalletsByWalletIDAtBlock( + arg_walletId [32]byte, + blockNumber *big.Int, +) (abi.WalletsWallet, error) { + var result abi.WalletsWallet + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "walletsByWalletID", + &result, + arg_walletId, + ) + + return result, err +} + +// ------ Events ------- + +func (b *Bridge) DepositParametersUpdatedEvent( + opts *ethereum.SubscribeOpts, +) *BDepositParametersUpdatedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BDepositParametersUpdatedSubscription{ + b, + opts, + } +} + +type BDepositParametersUpdatedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeDepositParametersUpdatedFunc func( + DepositDustThreshold uint64, + DepositTreasuryFeeDivisor uint64, + DepositTxMaxFee uint64, + DepositRevealAheadPeriod uint32, + blockNumber uint64, +) + +func (dpus *BDepositParametersUpdatedSubscription) OnEvent( + handler bridgeDepositParametersUpdatedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeDepositParametersUpdated) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.DepositDustThreshold, + event.DepositTreasuryFeeDivisor, + event.DepositTxMaxFee, + event.DepositRevealAheadPeriod, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := dpus.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (dpus *BDepositParametersUpdatedSubscription) Pipe( + sink chan *abi.BridgeDepositParametersUpdated, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(dpus.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := dpus.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - dpus.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past DepositParametersUpdated events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := dpus.contract.PastDepositParametersUpdatedEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past DepositParametersUpdated events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := dpus.contract.watchDepositParametersUpdated( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchDepositParametersUpdated( + sink chan *abi.BridgeDepositParametersUpdated, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchDepositParametersUpdated( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event DepositParametersUpdated had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event DepositParametersUpdated failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastDepositParametersUpdatedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeDepositParametersUpdated, error) { + iterator, err := b.contract.FilterDepositParametersUpdated( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past DepositParametersUpdated events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeDepositParametersUpdated, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (b *Bridge) DepositRevealedEvent( + opts *ethereum.SubscribeOpts, + depositorFilter []common.Address, + walletPubKeyHashFilter [][20]byte, +) *BDepositRevealedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BDepositRevealedSubscription{ + b, + opts, + depositorFilter, + walletPubKeyHashFilter, + } +} + +type BDepositRevealedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + depositorFilter []common.Address + walletPubKeyHashFilter [][20]byte +} + +type bridgeDepositRevealedFunc func( + FundingTxHash [32]byte, + FundingOutputIndex uint32, + Depositor common.Address, + Amount uint64, + BlindingFactor [8]byte, + WalletPubKeyHash [20]byte, + RefundPubKeyHash [20]byte, + RefundLocktime [4]byte, + Vault common.Address, + blockNumber uint64, +) + +func (drs *BDepositRevealedSubscription) OnEvent( + handler bridgeDepositRevealedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeDepositRevealed) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.FundingTxHash, + event.FundingOutputIndex, + event.Depositor, + event.Amount, + event.BlindingFactor, + event.WalletPubKeyHash, + event.RefundPubKeyHash, + event.RefundLocktime, + event.Vault, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := drs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (drs *BDepositRevealedSubscription) Pipe( + sink chan *abi.BridgeDepositRevealed, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(drs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := drs.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - drs.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past DepositRevealed events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := drs.contract.PastDepositRevealedEvents( + fromBlock, + nil, + drs.depositorFilter, + drs.walletPubKeyHashFilter, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past DepositRevealed events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := drs.contract.watchDepositRevealed( + sink, + drs.depositorFilter, + drs.walletPubKeyHashFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchDepositRevealed( + sink chan *abi.BridgeDepositRevealed, + depositorFilter []common.Address, + walletPubKeyHashFilter [][20]byte, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchDepositRevealed( + &bind.WatchOpts{Context: ctx}, + sink, + depositorFilter, + walletPubKeyHashFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event DepositRevealed had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event DepositRevealed failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastDepositRevealedEvents( + startBlock uint64, + endBlock *uint64, + depositorFilter []common.Address, + walletPubKeyHashFilter [][20]byte, +) ([]*abi.BridgeDepositRevealed, error) { + iterator, err := b.contract.FilterDepositRevealed( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + depositorFilter, + walletPubKeyHashFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past DepositRevealed events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeDepositRevealed, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (b *Bridge) DepositVaultFixedEvent( + opts *ethereum.SubscribeOpts, + depositKeyFilter []*big.Int, +) *BDepositVaultFixedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BDepositVaultFixedSubscription{ + b, + opts, + depositKeyFilter, + } +} + +type BDepositVaultFixedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + depositKeyFilter []*big.Int +} + +type bridgeDepositVaultFixedFunc func( + DepositKey *big.Int, + NewVault common.Address, + blockNumber uint64, +) + +func (dvfs *BDepositVaultFixedSubscription) OnEvent( + handler bridgeDepositVaultFixedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeDepositVaultFixed) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.DepositKey, + event.NewVault, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := dvfs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (dvfs *BDepositVaultFixedSubscription) Pipe( + sink chan *abi.BridgeDepositVaultFixed, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(dvfs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := dvfs.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - dvfs.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past DepositVaultFixed events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := dvfs.contract.PastDepositVaultFixedEvents( + fromBlock, + nil, + dvfs.depositKeyFilter, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past DepositVaultFixed events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := dvfs.contract.watchDepositVaultFixed( + sink, + dvfs.depositKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchDepositVaultFixed( + sink chan *abi.BridgeDepositVaultFixed, + depositKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchDepositVaultFixed( + &bind.WatchOpts{Context: ctx}, + sink, + depositKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event DepositVaultFixed had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event DepositVaultFixed failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastDepositVaultFixedEvents( + startBlock uint64, + endBlock *uint64, + depositKeyFilter []*big.Int, +) ([]*abi.BridgeDepositVaultFixed, error) { + iterator, err := b.contract.FilterDepositVaultFixed( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + depositKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past DepositVaultFixed events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeDepositVaultFixed, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (b *Bridge) DepositsSweptEvent( + opts *ethereum.SubscribeOpts, +) *BDepositsSweptSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BDepositsSweptSubscription{ + b, + opts, + } +} + +type BDepositsSweptSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeDepositsSweptFunc func( + WalletPubKeyHash [20]byte, + SweepTxHash [32]byte, + blockNumber uint64, +) + +func (dss *BDepositsSweptSubscription) OnEvent( + handler bridgeDepositsSweptFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeDepositsSwept) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.WalletPubKeyHash, + event.SweepTxHash, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := dss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (dss *BDepositsSweptSubscription) Pipe( + sink chan *abi.BridgeDepositsSwept, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(dss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := dss.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - dss.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past DepositsSwept events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := dss.contract.PastDepositsSweptEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past DepositsSwept events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := dss.contract.watchDepositsSwept( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchDepositsSwept( + sink chan *abi.BridgeDepositsSwept, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchDepositsSwept( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event DepositsSwept had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event DepositsSwept failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastDepositsSweptEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeDepositsSwept, error) { + iterator, err := b.contract.FilterDepositsSwept( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past DepositsSwept events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeDepositsSwept, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (b *Bridge) EcdsaFraudRouterSetEvent( + opts *ethereum.SubscribeOpts, +) *BEcdsaFraudRouterSetSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BEcdsaFraudRouterSetSubscription{ + b, + opts, + } +} + +type BEcdsaFraudRouterSetSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeEcdsaFraudRouterSetFunc func( + EcdsaFraudRouter common.Address, + blockNumber uint64, +) + +func (efrss *BEcdsaFraudRouterSetSubscription) OnEvent( + handler bridgeEcdsaFraudRouterSetFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeEcdsaFraudRouterSet) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.EcdsaFraudRouter, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := efrss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (efrss *BEcdsaFraudRouterSetSubscription) Pipe( + sink chan *abi.BridgeEcdsaFraudRouterSet, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(efrss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := efrss.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - efrss.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past EcdsaFraudRouterSet events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := efrss.contract.PastEcdsaFraudRouterSetEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past EcdsaFraudRouterSet events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := efrss.contract.watchEcdsaFraudRouterSet( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchEcdsaFraudRouterSet( + sink chan *abi.BridgeEcdsaFraudRouterSet, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchEcdsaFraudRouterSet( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event EcdsaFraudRouterSet had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event EcdsaFraudRouterSet failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastEcdsaFraudRouterSetEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeEcdsaFraudRouterSet, error) { + iterator, err := b.contract.FilterEcdsaFraudRouterSet( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past EcdsaFraudRouterSet events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeEcdsaFraudRouterSet, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (b *Bridge) EcdsaRetiredEvent( + opts *ethereum.SubscribeOpts, +) *BEcdsaRetiredSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BEcdsaRetiredSubscription{ + b, + opts, + } +} + +type BEcdsaRetiredSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeEcdsaRetiredFunc func( + blockNumber uint64, +) + +func (ers *BEcdsaRetiredSubscription) OnEvent( + handler bridgeEcdsaRetiredFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeEcdsaRetired) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.Raw.BlockNumber, + ) + } + } + }() + + sub := ers.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (ers *BEcdsaRetiredSubscription) Pipe( + sink chan *abi.BridgeEcdsaRetired, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(ers.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := ers.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - ers.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past EcdsaRetired events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := ers.contract.PastEcdsaRetiredEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past EcdsaRetired events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := ers.contract.watchEcdsaRetired( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchEcdsaRetired( + sink chan *abi.BridgeEcdsaRetired, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchEcdsaRetired( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event EcdsaRetired had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event EcdsaRetired failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastEcdsaRetiredEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeEcdsaRetired, error) { + iterator, err := b.contract.FilterEcdsaRetired( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past EcdsaRetired events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeEcdsaRetired, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (b *Bridge) FraudParametersUpdatedEvent( + opts *ethereum.SubscribeOpts, +) *BFraudParametersUpdatedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BFraudParametersUpdatedSubscription{ + b, + opts, + } +} + +type BFraudParametersUpdatedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeFraudParametersUpdatedFunc func( + FraudChallengeDepositAmount *big.Int, + FraudChallengeDefeatTimeout uint32, + FraudSlashingAmount *big.Int, + FraudNotifierRewardMultiplier uint32, + blockNumber uint64, +) + +func (fpus *BFraudParametersUpdatedSubscription) OnEvent( + handler bridgeFraudParametersUpdatedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeFraudParametersUpdated) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.FraudChallengeDepositAmount, + event.FraudChallengeDefeatTimeout, + event.FraudSlashingAmount, + event.FraudNotifierRewardMultiplier, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := fpus.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (fpus *BFraudParametersUpdatedSubscription) Pipe( + sink chan *abi.BridgeFraudParametersUpdated, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(fpus.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := fpus.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - fpus.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past FraudParametersUpdated events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := fpus.contract.PastFraudParametersUpdatedEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past FraudParametersUpdated events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := fpus.contract.watchFraudParametersUpdated( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchFraudParametersUpdated( + sink chan *abi.BridgeFraudParametersUpdated, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchFraudParametersUpdated( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event FraudParametersUpdated had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event FraudParametersUpdated failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastFraudParametersUpdatedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeFraudParametersUpdated, error) { + iterator, err := b.contract.FilterFraudParametersUpdated( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, ) - if err != nil { - return result, b.errorResolver.ResolveError( + return nil, fmt.Errorf( + "error retrieving past FraudParametersUpdated events: [%v]", err, - b.callerOptions.From, - nil, - "treasury", ) } - return result, err -} - -func (b *Bridge) TreasuryAtBlock( - blockNumber *big.Int, -) (common.Address, error) { - var result common.Address + events := make([]*abi.BridgeFraudParametersUpdated, 0) - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "treasury", - &result, - ) + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } - return result, err + return events, nil } -func (b *Bridge) TxProofDifficultyFactor() (*big.Int, error) { - result, err := b.contract.TxProofDifficultyFactor( - b.callerOptions, - ) +func (b *Bridge) FrostWalletRegistrySetEvent( + opts *ethereum.SubscribeOpts, +) *BFrostWalletRegistrySetSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "txProofDifficultyFactor", - ) + return &BFrostWalletRegistrySetSubscription{ + b, + opts, } +} - return result, err +type BFrostWalletRegistrySetSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts } -func (b *Bridge) TxProofDifficultyFactorAtBlock( - blockNumber *big.Int, -) (*big.Int, error) { - var result *big.Int +type bridgeFrostWalletRegistrySetFunc func( + FrostWalletRegistry common.Address, + blockNumber uint64, +) - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "txProofDifficultyFactor", - &result, - ) +func (fwrss *BFrostWalletRegistrySetSubscription) OnEvent( + handler bridgeFrostWalletRegistrySetFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeFrostWalletRegistrySet) + ctx, cancelCtx := context.WithCancel(context.Background()) - return result, err -} + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.FrostWalletRegistry, + event.Raw.BlockNumber, + ) + } + } + }() -type walletParameters struct { - WalletCreationPeriod uint32 - WalletCreationMinBtcBalance uint64 - WalletCreationMaxBtcBalance uint64 - WalletClosureMinBtcBalance uint64 - WalletMaxAge uint32 - WalletMaxBtcTransfer uint64 - WalletClosingPeriod uint32 + sub := fwrss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) } -func (b *Bridge) WalletParameters() (walletParameters, error) { - result, err := b.contract.WalletParameters( - b.callerOptions, +func (fwrss *BFrostWalletRegistrySetSubscription) Pipe( + sink chan *abi.BridgeFrostWalletRegistrySet, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(fwrss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := fwrss.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - fwrss.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past FrostWalletRegistrySet events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := fwrss.contract.PastFrostWalletRegistrySetEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past FrostWalletRegistrySet events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := fwrss.contract.watchFrostWalletRegistrySet( + sink, ) - if err != nil { - return result, b.errorResolver.ResolveError( - err, - b.callerOptions.From, - nil, - "walletParameters", + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchFrostWalletRegistrySet( + sink chan *abi.BridgeFrostWalletRegistrySet, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchFrostWalletRegistrySet( + &bind.WatchOpts{Context: ctx}, + sink, ) } - return result, err -} + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event FrostWalletRegistrySet had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } -func (b *Bridge) WalletParametersAtBlock( - blockNumber *big.Int, -) (walletParameters, error) { - var result walletParameters + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event FrostWalletRegistrySet failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "walletParameters", - &result, + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, ) - - return result, err } -func (b *Bridge) Wallets( - arg_walletPubKeyHash [20]byte, -) (abi.WalletsWallet, error) { - result, err := b.contract.Wallets( - b.callerOptions, - arg_walletPubKeyHash, +func (b *Bridge) PastFrostWalletRegistrySetEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeFrostWalletRegistrySet, error) { + iterator, err := b.contract.FilterFrostWalletRegistrySet( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, ) - if err != nil { - return result, b.errorResolver.ResolveError( + return nil, fmt.Errorf( + "error retrieving past FrostWalletRegistrySet events: [%v]", err, - b.callerOptions.From, - nil, - "wallets", - arg_walletPubKeyHash, ) } - return result, err -} - -func (b *Bridge) WalletsAtBlock( - arg_walletPubKeyHash [20]byte, - blockNumber *big.Int, -) (abi.WalletsWallet, error) { - var result abi.WalletsWallet + events := make([]*abi.BridgeFrostWalletRegistrySet, 0) - err := chainutil.CallAtBlock( - b.callerOptions.From, - blockNumber, - nil, - b.contractABI, - b.caller, - b.errorResolver, - b.contractAddress, - "wallets", - &result, - arg_walletPubKeyHash, - ) + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } - return result, err + return events, nil } -// ------ Events ------- - -func (b *Bridge) DepositParametersUpdatedEvent( +func (b *Bridge) GovernanceTransferredEvent( opts *ethereum.SubscribeOpts, -) *BDepositParametersUpdatedSubscription { +) *BGovernanceTransferredSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -6564,29 +9438,27 @@ func (b *Bridge) DepositParametersUpdatedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BDepositParametersUpdatedSubscription{ + return &BGovernanceTransferredSubscription{ b, opts, } } -type BDepositParametersUpdatedSubscription struct { +type BGovernanceTransferredSubscription struct { contract *Bridge opts *ethereum.SubscribeOpts } -type bridgeDepositParametersUpdatedFunc func( - DepositDustThreshold uint64, - DepositTreasuryFeeDivisor uint64, - DepositTxMaxFee uint64, - DepositRevealAheadPeriod uint32, +type bridgeGovernanceTransferredFunc func( + OldGovernance common.Address, + NewGovernance common.Address, blockNumber uint64, ) -func (dpus *BDepositParametersUpdatedSubscription) OnEvent( - handler bridgeDepositParametersUpdatedFunc, +func (gts *BGovernanceTransferredSubscription) OnEvent( + handler bridgeGovernanceTransferredFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeDepositParametersUpdated) + eventChan := make(chan *abi.BridgeGovernanceTransferred) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -6596,50 +9468,48 @@ func (dpus *BDepositParametersUpdatedSubscription) OnEvent( return case event := <-eventChan: handler( - event.DepositDustThreshold, - event.DepositTreasuryFeeDivisor, - event.DepositTxMaxFee, - event.DepositRevealAheadPeriod, + event.OldGovernance, + event.NewGovernance, event.Raw.BlockNumber, ) } } }() - sub := dpus.Pipe(eventChan) + sub := gts.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (dpus *BDepositParametersUpdatedSubscription) Pipe( - sink chan *abi.BridgeDepositParametersUpdated, +func (gts *BGovernanceTransferredSubscription) Pipe( + sink chan *abi.BridgeGovernanceTransferred, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(dpus.opts.Tick) + ticker := time.NewTicker(gts.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := dpus.contract.blockCounter.CurrentBlock() + lastBlock, err := gts.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - dpus.opts.PastBlocks + fromBlock := lastBlock - gts.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past DepositParametersUpdated events "+ + "subscription monitoring fetching past GovernanceTransferred events "+ "starting from block [%v]", fromBlock, ) - events, err := dpus.contract.PastDepositParametersUpdatedEvents( + events, err := gts.contract.PastGovernanceTransferredEvents( fromBlock, nil, ) @@ -6651,7 +9521,7 @@ func (dpus *BDepositParametersUpdatedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past DepositParametersUpdated events", + "subscription monitoring fetched [%v] past GovernanceTransferred events", len(events), ) @@ -6662,7 +9532,7 @@ func (dpus *BDepositParametersUpdatedSubscription) Pipe( } }() - sub := dpus.contract.watchDepositParametersUpdated( + sub := gts.contract.watchGovernanceTransferred( sink, ) @@ -6672,11 +9542,11 @@ func (dpus *BDepositParametersUpdatedSubscription) Pipe( }) } -func (b *Bridge) watchDepositParametersUpdated( - sink chan *abi.BridgeDepositParametersUpdated, +func (b *Bridge) watchGovernanceTransferred( + sink chan *abi.BridgeGovernanceTransferred, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchDepositParametersUpdated( + return b.contract.WatchGovernanceTransferred( &bind.WatchOpts{Context: ctx}, sink, ) @@ -6684,7 +9554,7 @@ func (b *Bridge) watchDepositParametersUpdated( thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event DepositParametersUpdated had to be "+ + "subscription to event GovernanceTransferred had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -6693,7 +9563,7 @@ func (b *Bridge) watchDepositParametersUpdated( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event DepositParametersUpdated failed "+ + "subscription to event GovernanceTransferred failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -6709,11 +9579,11 @@ func (b *Bridge) watchDepositParametersUpdated( ) } -func (b *Bridge) PastDepositParametersUpdatedEvents( +func (b *Bridge) PastGovernanceTransferredEvents( startBlock uint64, endBlock *uint64, -) ([]*abi.BridgeDepositParametersUpdated, error) { - iterator, err := b.contract.FilterDepositParametersUpdated( +) ([]*abi.BridgeGovernanceTransferred, error) { + iterator, err := b.contract.FilterGovernanceTransferred( &bind.FilterOpts{ Start: startBlock, End: endBlock, @@ -6721,12 +9591,12 @@ func (b *Bridge) PastDepositParametersUpdatedEvents( ) if err != nil { return nil, fmt.Errorf( - "error retrieving past DepositParametersUpdated events: [%v]", + "error retrieving past GovernanceTransferred events: [%v]", err, ) } - events := make([]*abi.BridgeDepositParametersUpdated, 0) + events := make([]*abi.BridgeGovernanceTransferred, 0) for iterator.Next() { event := iterator.Event @@ -6736,11 +9606,9 @@ func (b *Bridge) PastDepositParametersUpdatedEvents( return events, nil } -func (b *Bridge) DepositRevealedEvent( +func (b *Bridge) InitializedEvent( opts *ethereum.SubscribeOpts, - depositorFilter []common.Address, - walletPubKeyHashFilter [][20]byte, -) *BDepositRevealedSubscription { +) *BInitializedSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -6751,38 +9619,26 @@ func (b *Bridge) DepositRevealedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BDepositRevealedSubscription{ + return &BInitializedSubscription{ b, opts, - depositorFilter, - walletPubKeyHashFilter, } } -type BDepositRevealedSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts - depositorFilter []common.Address - walletPubKeyHashFilter [][20]byte +type BInitializedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts } -type bridgeDepositRevealedFunc func( - FundingTxHash [32]byte, - FundingOutputIndex uint32, - Depositor common.Address, - Amount uint64, - BlindingFactor [8]byte, - WalletPubKeyHash [20]byte, - RefundPubKeyHash [20]byte, - RefundLocktime [4]byte, - Vault common.Address, +type bridgeInitializedFunc func( + Version uint8, blockNumber uint64, ) -func (drs *BDepositRevealedSubscription) OnEvent( - handler bridgeDepositRevealedFunc, +func (is *BInitializedSubscription) OnEvent( + handler bridgeInitializedFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeDepositRevealed) + eventChan := make(chan *abi.BridgeInitialized) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -6792,59 +9648,49 @@ func (drs *BDepositRevealedSubscription) OnEvent( return case event := <-eventChan: handler( - event.FundingTxHash, - event.FundingOutputIndex, - event.Depositor, - event.Amount, - event.BlindingFactor, - event.WalletPubKeyHash, - event.RefundPubKeyHash, - event.RefundLocktime, - event.Vault, + event.Version, event.Raw.BlockNumber, ) } } }() - sub := drs.Pipe(eventChan) + sub := is.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (drs *BDepositRevealedSubscription) Pipe( - sink chan *abi.BridgeDepositRevealed, +func (is *BInitializedSubscription) Pipe( + sink chan *abi.BridgeInitialized, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(drs.opts.Tick) + ticker := time.NewTicker(is.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := drs.contract.blockCounter.CurrentBlock() + lastBlock, err := is.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - drs.opts.PastBlocks + fromBlock := lastBlock - is.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past DepositRevealed events "+ + "subscription monitoring fetching past Initialized events "+ "starting from block [%v]", fromBlock, ) - events, err := drs.contract.PastDepositRevealedEvents( + events, err := is.contract.PastInitializedEvents( fromBlock, nil, - drs.depositorFilter, - drs.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -6854,7 +9700,7 @@ func (drs *BDepositRevealedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past DepositRevealed events", + "subscription monitoring fetched [%v] past Initialized events", len(events), ) @@ -6865,10 +9711,8 @@ func (drs *BDepositRevealedSubscription) Pipe( } }() - sub := drs.contract.watchDepositRevealed( + sub := is.contract.watchInitialized( sink, - drs.depositorFilter, - drs.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -6877,23 +9721,19 @@ func (drs *BDepositRevealedSubscription) Pipe( }) } -func (b *Bridge) watchDepositRevealed( - sink chan *abi.BridgeDepositRevealed, - depositorFilter []common.Address, - walletPubKeyHashFilter [][20]byte, +func (b *Bridge) watchInitialized( + sink chan *abi.BridgeInitialized, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchDepositRevealed( + return b.contract.WatchInitialized( &bind.WatchOpts{Context: ctx}, sink, - depositorFilter, - walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event DepositRevealed had to be "+ + "subscription to event Initialized had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -6902,7 +9742,7 @@ func (b *Bridge) watchDepositRevealed( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event DepositRevealed failed "+ + "subscription to event Initialized failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -6918,28 +9758,24 @@ func (b *Bridge) watchDepositRevealed( ) } -func (b *Bridge) PastDepositRevealedEvents( +func (b *Bridge) PastInitializedEvents( startBlock uint64, endBlock *uint64, - depositorFilter []common.Address, - walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeDepositRevealed, error) { - iterator, err := b.contract.FilterDepositRevealed( +) ([]*abi.BridgeInitialized, error) { + iterator, err := b.contract.FilterInitialized( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, - depositorFilter, - walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past DepositRevealed events: [%v]", + "error retrieving past Initialized events: [%v]", err, ) } - events := make([]*abi.BridgeDepositRevealed, 0) + events := make([]*abi.BridgeInitialized, 0) for iterator.Next() { event := iterator.Event @@ -6949,9 +9785,12 @@ func (b *Bridge) PastDepositRevealedEvents( return events, nil } -func (b *Bridge) DepositsSweptEvent( +func (b *Bridge) LegacyFraudChallengeMigratedEvent( opts *ethereum.SubscribeOpts, -) *BDepositsSweptSubscription { + routerKindFilter []uint8, + challengeKeyFilter []*big.Int, + challengerFilter []common.Address, +) *BLegacyFraudChallengeMigratedSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -6962,27 +9801,35 @@ func (b *Bridge) DepositsSweptEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BDepositsSweptSubscription{ + return &BLegacyFraudChallengeMigratedSubscription{ b, opts, + routerKindFilter, + challengeKeyFilter, + challengerFilter, } } -type BDepositsSweptSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts +type BLegacyFraudChallengeMigratedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + routerKindFilter []uint8 + challengeKeyFilter []*big.Int + challengerFilter []common.Address } -type bridgeDepositsSweptFunc func( - WalletPubKeyHash [20]byte, - SweepTxHash [32]byte, +type bridgeLegacyFraudChallengeMigratedFunc func( + RouterKind uint8, + ChallengeKey *big.Int, + Challenger common.Address, + DepositAmount *big.Int, blockNumber uint64, ) -func (dss *BDepositsSweptSubscription) OnEvent( - handler bridgeDepositsSweptFunc, +func (lfcms *BLegacyFraudChallengeMigratedSubscription) OnEvent( + handler bridgeLegacyFraudChallengeMigratedFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeDepositsSwept) + eventChan := make(chan *abi.BridgeLegacyFraudChallengeMigrated) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -6992,50 +9839,55 @@ func (dss *BDepositsSweptSubscription) OnEvent( return case event := <-eventChan: handler( - event.WalletPubKeyHash, - event.SweepTxHash, + event.RouterKind, + event.ChallengeKey, + event.Challenger, + event.DepositAmount, event.Raw.BlockNumber, ) } } }() - sub := dss.Pipe(eventChan) + sub := lfcms.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (dss *BDepositsSweptSubscription) Pipe( - sink chan *abi.BridgeDepositsSwept, +func (lfcms *BLegacyFraudChallengeMigratedSubscription) Pipe( + sink chan *abi.BridgeLegacyFraudChallengeMigrated, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(dss.opts.Tick) + ticker := time.NewTicker(lfcms.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := dss.contract.blockCounter.CurrentBlock() + lastBlock, err := lfcms.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - dss.opts.PastBlocks + fromBlock := lastBlock - lfcms.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past DepositsSwept events "+ + "subscription monitoring fetching past LegacyFraudChallengeMigrated events "+ "starting from block [%v]", fromBlock, ) - events, err := dss.contract.PastDepositsSweptEvents( + events, err := lfcms.contract.PastLegacyFraudChallengeMigratedEvents( fromBlock, nil, + lfcms.routerKindFilter, + lfcms.challengeKeyFilter, + lfcms.challengerFilter, ) if err != nil { bLogger.Errorf( @@ -7045,7 +9897,7 @@ func (dss *BDepositsSweptSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past DepositsSwept events", + "subscription monitoring fetched [%v] past LegacyFraudChallengeMigrated events", len(events), ) @@ -7056,8 +9908,11 @@ func (dss *BDepositsSweptSubscription) Pipe( } }() - sub := dss.contract.watchDepositsSwept( + sub := lfcms.contract.watchLegacyFraudChallengeMigrated( sink, + lfcms.routerKindFilter, + lfcms.challengeKeyFilter, + lfcms.challengerFilter, ) return subscription.NewEventSubscription(func() { @@ -7066,19 +9921,25 @@ func (dss *BDepositsSweptSubscription) Pipe( }) } -func (b *Bridge) watchDepositsSwept( - sink chan *abi.BridgeDepositsSwept, +func (b *Bridge) watchLegacyFraudChallengeMigrated( + sink chan *abi.BridgeLegacyFraudChallengeMigrated, + routerKindFilter []uint8, + challengeKeyFilter []*big.Int, + challengerFilter []common.Address, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchDepositsSwept( + return b.contract.WatchLegacyFraudChallengeMigrated( &bind.WatchOpts{Context: ctx}, sink, + routerKindFilter, + challengeKeyFilter, + challengerFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event DepositsSwept had to be "+ + "subscription to event LegacyFraudChallengeMigrated had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -7087,7 +9948,7 @@ func (b *Bridge) watchDepositsSwept( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event DepositsSwept failed "+ + "subscription to event LegacyFraudChallengeMigrated failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -7103,24 +9964,30 @@ func (b *Bridge) watchDepositsSwept( ) } -func (b *Bridge) PastDepositsSweptEvents( +func (b *Bridge) PastLegacyFraudChallengeMigratedEvents( startBlock uint64, endBlock *uint64, -) ([]*abi.BridgeDepositsSwept, error) { - iterator, err := b.contract.FilterDepositsSwept( + routerKindFilter []uint8, + challengeKeyFilter []*big.Int, + challengerFilter []common.Address, +) ([]*abi.BridgeLegacyFraudChallengeMigrated, error) { + iterator, err := b.contract.FilterLegacyFraudChallengeMigrated( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, + routerKindFilter, + challengeKeyFilter, + challengerFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past DepositsSwept events: [%v]", + "error retrieving past LegacyFraudChallengeMigrated events: [%v]", err, ) } - events := make([]*abi.BridgeDepositsSwept, 0) + events := make([]*abi.BridgeLegacyFraudChallengeMigrated, 0) for iterator.Next() { event := iterator.Event @@ -7130,10 +9997,9 @@ func (b *Bridge) PastDepositsSweptEvents( return events, nil } -func (b *Bridge) FraudChallengeDefeatTimedOutEvent( +func (b *Bridge) LifecycleRouterSetEvent( opts *ethereum.SubscribeOpts, - walletPubKeyHashFilter [][20]byte, -) *BFraudChallengeDefeatTimedOutSubscription { +) *BLifecycleRouterSetSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -7144,29 +10010,26 @@ func (b *Bridge) FraudChallengeDefeatTimedOutEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BFraudChallengeDefeatTimedOutSubscription{ + return &BLifecycleRouterSetSubscription{ b, opts, - walletPubKeyHashFilter, } } -type BFraudChallengeDefeatTimedOutSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts - walletPubKeyHashFilter [][20]byte +type BLifecycleRouterSetSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts } -type bridgeFraudChallengeDefeatTimedOutFunc func( - WalletPubKeyHash [20]byte, - Sighash [32]byte, +type bridgeLifecycleRouterSetFunc func( + LifecycleRouter common.Address, blockNumber uint64, ) -func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) OnEvent( - handler bridgeFraudChallengeDefeatTimedOutFunc, +func (lrss *BLifecycleRouterSetSubscription) OnEvent( + handler bridgeLifecycleRouterSetFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeFraudChallengeDefeatTimedOut) + eventChan := make(chan *abi.BridgeLifecycleRouterSet) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -7176,51 +10039,49 @@ func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) OnEvent( return case event := <-eventChan: handler( - event.WalletPubKeyHash, - event.Sighash, + event.LifecycleRouter, event.Raw.BlockNumber, ) } } }() - sub := fcdtos.Pipe(eventChan) + sub := lrss.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) Pipe( - sink chan *abi.BridgeFraudChallengeDefeatTimedOut, +func (lrss *BLifecycleRouterSetSubscription) Pipe( + sink chan *abi.BridgeLifecycleRouterSet, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(fcdtos.opts.Tick) + ticker := time.NewTicker(lrss.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := fcdtos.contract.blockCounter.CurrentBlock() + lastBlock, err := lrss.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - fcdtos.opts.PastBlocks + fromBlock := lastBlock - lrss.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past FraudChallengeDefeatTimedOut events "+ + "subscription monitoring fetching past LifecycleRouterSet events "+ "starting from block [%v]", fromBlock, ) - events, err := fcdtos.contract.PastFraudChallengeDefeatTimedOutEvents( + events, err := lrss.contract.PastLifecycleRouterSetEvents( fromBlock, nil, - fcdtos.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -7230,7 +10091,7 @@ func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past FraudChallengeDefeatTimedOut events", + "subscription monitoring fetched [%v] past LifecycleRouterSet events", len(events), ) @@ -7241,9 +10102,8 @@ func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) Pipe( } }() - sub := fcdtos.contract.watchFraudChallengeDefeatTimedOut( + sub := lrss.contract.watchLifecycleRouterSet( sink, - fcdtos.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -7252,21 +10112,19 @@ func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) Pipe( }) } -func (b *Bridge) watchFraudChallengeDefeatTimedOut( - sink chan *abi.BridgeFraudChallengeDefeatTimedOut, - walletPubKeyHashFilter [][20]byte, +func (b *Bridge) watchLifecycleRouterSet( + sink chan *abi.BridgeLifecycleRouterSet, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchFraudChallengeDefeatTimedOut( + return b.contract.WatchLifecycleRouterSet( &bind.WatchOpts{Context: ctx}, sink, - walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event FraudChallengeDefeatTimedOut had to be "+ + "subscription to event LifecycleRouterSet had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -7275,7 +10133,7 @@ func (b *Bridge) watchFraudChallengeDefeatTimedOut( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event FraudChallengeDefeatTimedOut failed "+ + "subscription to event LifecycleRouterSet failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -7291,26 +10149,24 @@ func (b *Bridge) watchFraudChallengeDefeatTimedOut( ) } -func (b *Bridge) PastFraudChallengeDefeatTimedOutEvents( +func (b *Bridge) PastLifecycleRouterSetEvents( startBlock uint64, endBlock *uint64, - walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeFraudChallengeDefeatTimedOut, error) { - iterator, err := b.contract.FilterFraudChallengeDefeatTimedOut( +) ([]*abi.BridgeLifecycleRouterSet, error) { + iterator, err := b.contract.FilterLifecycleRouterSet( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, - walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past FraudChallengeDefeatTimedOut events: [%v]", + "error retrieving past LifecycleRouterSet events: [%v]", err, ) } - events := make([]*abi.BridgeFraudChallengeDefeatTimedOut, 0) + events := make([]*abi.BridgeLifecycleRouterSet, 0) for iterator.Next() { event := iterator.Event @@ -7320,10 +10176,10 @@ func (b *Bridge) PastFraudChallengeDefeatTimedOutEvents( return events, nil } -func (b *Bridge) FraudChallengeDefeatedEvent( +func (b *Bridge) MovedFundsSweepTimedOutEvent( opts *ethereum.SubscribeOpts, walletPubKeyHashFilter [][20]byte, -) *BFraudChallengeDefeatedSubscription { +) *BMovedFundsSweepTimedOutSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -7334,29 +10190,30 @@ func (b *Bridge) FraudChallengeDefeatedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BFraudChallengeDefeatedSubscription{ + return &BMovedFundsSweepTimedOutSubscription{ b, opts, walletPubKeyHashFilter, } } -type BFraudChallengeDefeatedSubscription struct { +type BMovedFundsSweepTimedOutSubscription struct { contract *Bridge opts *ethereum.SubscribeOpts walletPubKeyHashFilter [][20]byte } -type bridgeFraudChallengeDefeatedFunc func( +type bridgeMovedFundsSweepTimedOutFunc func( WalletPubKeyHash [20]byte, - Sighash [32]byte, + MovingFundsTxHash [32]byte, + MovingFundsTxOutputIndex uint32, blockNumber uint64, ) -func (fcds *BFraudChallengeDefeatedSubscription) OnEvent( - handler bridgeFraudChallengeDefeatedFunc, +func (mfstos *BMovedFundsSweepTimedOutSubscription) OnEvent( + handler bridgeMovedFundsSweepTimedOutFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeFraudChallengeDefeated) + eventChan := make(chan *abi.BridgeMovedFundsSweepTimedOut) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -7367,50 +10224,51 @@ func (fcds *BFraudChallengeDefeatedSubscription) OnEvent( case event := <-eventChan: handler( event.WalletPubKeyHash, - event.Sighash, + event.MovingFundsTxHash, + event.MovingFundsTxOutputIndex, event.Raw.BlockNumber, ) } } }() - sub := fcds.Pipe(eventChan) + sub := mfstos.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (fcds *BFraudChallengeDefeatedSubscription) Pipe( - sink chan *abi.BridgeFraudChallengeDefeated, +func (mfstos *BMovedFundsSweepTimedOutSubscription) Pipe( + sink chan *abi.BridgeMovedFundsSweepTimedOut, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(fcds.opts.Tick) + ticker := time.NewTicker(mfstos.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := fcds.contract.blockCounter.CurrentBlock() + lastBlock, err := mfstos.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - fcds.opts.PastBlocks + fromBlock := lastBlock - mfstos.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past FraudChallengeDefeated events "+ + "subscription monitoring fetching past MovedFundsSweepTimedOut events "+ "starting from block [%v]", fromBlock, ) - events, err := fcds.contract.PastFraudChallengeDefeatedEvents( + events, err := mfstos.contract.PastMovedFundsSweepTimedOutEvents( fromBlock, nil, - fcds.walletPubKeyHashFilter, + mfstos.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -7420,7 +10278,7 @@ func (fcds *BFraudChallengeDefeatedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past FraudChallengeDefeated events", + "subscription monitoring fetched [%v] past MovedFundsSweepTimedOut events", len(events), ) @@ -7431,9 +10289,9 @@ func (fcds *BFraudChallengeDefeatedSubscription) Pipe( } }() - sub := fcds.contract.watchFraudChallengeDefeated( + sub := mfstos.contract.watchMovedFundsSweepTimedOut( sink, - fcds.walletPubKeyHashFilter, + mfstos.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -7442,12 +10300,12 @@ func (fcds *BFraudChallengeDefeatedSubscription) Pipe( }) } -func (b *Bridge) watchFraudChallengeDefeated( - sink chan *abi.BridgeFraudChallengeDefeated, +func (b *Bridge) watchMovedFundsSweepTimedOut( + sink chan *abi.BridgeMovedFundsSweepTimedOut, walletPubKeyHashFilter [][20]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchFraudChallengeDefeated( + return b.contract.WatchMovedFundsSweepTimedOut( &bind.WatchOpts{Context: ctx}, sink, walletPubKeyHashFilter, @@ -7456,7 +10314,7 @@ func (b *Bridge) watchFraudChallengeDefeated( thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event FraudChallengeDefeated had to be "+ + "subscription to event MovedFundsSweepTimedOut had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -7465,7 +10323,7 @@ func (b *Bridge) watchFraudChallengeDefeated( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event FraudChallengeDefeated failed "+ + "subscription to event MovedFundsSweepTimedOut failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -7481,12 +10339,12 @@ func (b *Bridge) watchFraudChallengeDefeated( ) } -func (b *Bridge) PastFraudChallengeDefeatedEvents( +func (b *Bridge) PastMovedFundsSweepTimedOutEvents( startBlock uint64, endBlock *uint64, walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeFraudChallengeDefeated, error) { - iterator, err := b.contract.FilterFraudChallengeDefeated( +) ([]*abi.BridgeMovedFundsSweepTimedOut, error) { + iterator, err := b.contract.FilterMovedFundsSweepTimedOut( &bind.FilterOpts{ Start: startBlock, End: endBlock, @@ -7495,12 +10353,12 @@ func (b *Bridge) PastFraudChallengeDefeatedEvents( ) if err != nil { return nil, fmt.Errorf( - "error retrieving past FraudChallengeDefeated events: [%v]", + "error retrieving past MovedFundsSweepTimedOut events: [%v]", err, ) } - events := make([]*abi.BridgeFraudChallengeDefeated, 0) + events := make([]*abi.BridgeMovedFundsSweepTimedOut, 0) for iterator.Next() { event := iterator.Event @@ -7510,10 +10368,10 @@ func (b *Bridge) PastFraudChallengeDefeatedEvents( return events, nil } -func (b *Bridge) FraudChallengeSubmittedEvent( +func (b *Bridge) MovedFundsSweptEvent( opts *ethereum.SubscribeOpts, walletPubKeyHashFilter [][20]byte, -) *BFraudChallengeSubmittedSubscription { +) *BMovedFundsSweptSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -7524,32 +10382,29 @@ func (b *Bridge) FraudChallengeSubmittedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BFraudChallengeSubmittedSubscription{ + return &BMovedFundsSweptSubscription{ b, opts, walletPubKeyHashFilter, } } -type BFraudChallengeSubmittedSubscription struct { +type BMovedFundsSweptSubscription struct { contract *Bridge opts *ethereum.SubscribeOpts walletPubKeyHashFilter [][20]byte } -type bridgeFraudChallengeSubmittedFunc func( +type bridgeMovedFundsSweptFunc func( WalletPubKeyHash [20]byte, - Sighash [32]byte, - V uint8, - R [32]byte, - S [32]byte, + SweepTxHash [32]byte, blockNumber uint64, ) -func (fcss *BFraudChallengeSubmittedSubscription) OnEvent( - handler bridgeFraudChallengeSubmittedFunc, +func (mfss *BMovedFundsSweptSubscription) OnEvent( + handler bridgeMovedFundsSweptFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeFraudChallengeSubmitted) + eventChan := make(chan *abi.BridgeMovedFundsSwept) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -7560,53 +10415,50 @@ func (fcss *BFraudChallengeSubmittedSubscription) OnEvent( case event := <-eventChan: handler( event.WalletPubKeyHash, - event.Sighash, - event.V, - event.R, - event.S, + event.SweepTxHash, event.Raw.BlockNumber, ) } } }() - sub := fcss.Pipe(eventChan) + sub := mfss.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (fcss *BFraudChallengeSubmittedSubscription) Pipe( - sink chan *abi.BridgeFraudChallengeSubmitted, +func (mfss *BMovedFundsSweptSubscription) Pipe( + sink chan *abi.BridgeMovedFundsSwept, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(fcss.opts.Tick) + ticker := time.NewTicker(mfss.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := fcss.contract.blockCounter.CurrentBlock() + lastBlock, err := mfss.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - fcss.opts.PastBlocks + fromBlock := lastBlock - mfss.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past FraudChallengeSubmitted events "+ + "subscription monitoring fetching past MovedFundsSwept events "+ "starting from block [%v]", fromBlock, ) - events, err := fcss.contract.PastFraudChallengeSubmittedEvents( + events, err := mfss.contract.PastMovedFundsSweptEvents( fromBlock, nil, - fcss.walletPubKeyHashFilter, + mfss.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -7616,7 +10468,7 @@ func (fcss *BFraudChallengeSubmittedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past FraudChallengeSubmitted events", + "subscription monitoring fetched [%v] past MovedFundsSwept events", len(events), ) @@ -7627,9 +10479,9 @@ func (fcss *BFraudChallengeSubmittedSubscription) Pipe( } }() - sub := fcss.contract.watchFraudChallengeSubmitted( + sub := mfss.contract.watchMovedFundsSwept( sink, - fcss.walletPubKeyHashFilter, + mfss.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -7638,12 +10490,12 @@ func (fcss *BFraudChallengeSubmittedSubscription) Pipe( }) } -func (b *Bridge) watchFraudChallengeSubmitted( - sink chan *abi.BridgeFraudChallengeSubmitted, +func (b *Bridge) watchMovedFundsSwept( + sink chan *abi.BridgeMovedFundsSwept, walletPubKeyHashFilter [][20]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchFraudChallengeSubmitted( + return b.contract.WatchMovedFundsSwept( &bind.WatchOpts{Context: ctx}, sink, walletPubKeyHashFilter, @@ -7652,7 +10504,7 @@ func (b *Bridge) watchFraudChallengeSubmitted( thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event FraudChallengeSubmitted had to be "+ + "subscription to event MovedFundsSwept had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -7661,7 +10513,7 @@ func (b *Bridge) watchFraudChallengeSubmitted( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event FraudChallengeSubmitted failed "+ + "subscription to event MovedFundsSwept failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -7677,12 +10529,12 @@ func (b *Bridge) watchFraudChallengeSubmitted( ) } -func (b *Bridge) PastFraudChallengeSubmittedEvents( +func (b *Bridge) PastMovedFundsSweptEvents( startBlock uint64, endBlock *uint64, walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeFraudChallengeSubmitted, error) { - iterator, err := b.contract.FilterFraudChallengeSubmitted( +) ([]*abi.BridgeMovedFundsSwept, error) { + iterator, err := b.contract.FilterMovedFundsSwept( &bind.FilterOpts{ Start: startBlock, End: endBlock, @@ -7691,12 +10543,12 @@ func (b *Bridge) PastFraudChallengeSubmittedEvents( ) if err != nil { return nil, fmt.Errorf( - "error retrieving past FraudChallengeSubmitted events: [%v]", + "error retrieving past MovedFundsSwept events: [%v]", err, ) } - events := make([]*abi.BridgeFraudChallengeSubmitted, 0) + events := make([]*abi.BridgeMovedFundsSwept, 0) for iterator.Next() { event := iterator.Event @@ -7706,9 +10558,10 @@ func (b *Bridge) PastFraudChallengeSubmittedEvents( return events, nil } -func (b *Bridge) FraudParametersUpdatedEvent( +func (b *Bridge) MovingFundsBelowDustReportedEvent( opts *ethereum.SubscribeOpts, -) *BFraudParametersUpdatedSubscription { + walletPubKeyHashFilter [][20]byte, +) *BMovingFundsBelowDustReportedSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -7719,29 +10572,28 @@ func (b *Bridge) FraudParametersUpdatedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BFraudParametersUpdatedSubscription{ + return &BMovingFundsBelowDustReportedSubscription{ b, opts, + walletPubKeyHashFilter, } } -type BFraudParametersUpdatedSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts +type BMovingFundsBelowDustReportedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + walletPubKeyHashFilter [][20]byte } -type bridgeFraudParametersUpdatedFunc func( - FraudChallengeDepositAmount *big.Int, - FraudChallengeDefeatTimeout uint32, - FraudSlashingAmount *big.Int, - FraudNotifierRewardMultiplier uint32, +type bridgeMovingFundsBelowDustReportedFunc func( + WalletPubKeyHash [20]byte, blockNumber uint64, ) -func (fpus *BFraudParametersUpdatedSubscription) OnEvent( - handler bridgeFraudParametersUpdatedFunc, +func (mfbdrs *BMovingFundsBelowDustReportedSubscription) OnEvent( + handler bridgeMovingFundsBelowDustReportedFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeFraudParametersUpdated) + eventChan := make(chan *abi.BridgeMovingFundsBelowDustReported) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -7751,52 +10603,50 @@ func (fpus *BFraudParametersUpdatedSubscription) OnEvent( return case event := <-eventChan: handler( - event.FraudChallengeDepositAmount, - event.FraudChallengeDefeatTimeout, - event.FraudSlashingAmount, - event.FraudNotifierRewardMultiplier, + event.WalletPubKeyHash, event.Raw.BlockNumber, ) } } }() - sub := fpus.Pipe(eventChan) + sub := mfbdrs.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (fpus *BFraudParametersUpdatedSubscription) Pipe( - sink chan *abi.BridgeFraudParametersUpdated, +func (mfbdrs *BMovingFundsBelowDustReportedSubscription) Pipe( + sink chan *abi.BridgeMovingFundsBelowDustReported, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(fpus.opts.Tick) + ticker := time.NewTicker(mfbdrs.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := fpus.contract.blockCounter.CurrentBlock() + lastBlock, err := mfbdrs.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - fpus.opts.PastBlocks + fromBlock := lastBlock - mfbdrs.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past FraudParametersUpdated events "+ + "subscription monitoring fetching past MovingFundsBelowDustReported events "+ "starting from block [%v]", fromBlock, ) - events, err := fpus.contract.PastFraudParametersUpdatedEvents( + events, err := mfbdrs.contract.PastMovingFundsBelowDustReportedEvents( fromBlock, nil, + mfbdrs.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -7806,7 +10656,7 @@ func (fpus *BFraudParametersUpdatedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past FraudParametersUpdated events", + "subscription monitoring fetched [%v] past MovingFundsBelowDustReported events", len(events), ) @@ -7817,8 +10667,9 @@ func (fpus *BFraudParametersUpdatedSubscription) Pipe( } }() - sub := fpus.contract.watchFraudParametersUpdated( + sub := mfbdrs.contract.watchMovingFundsBelowDustReported( sink, + mfbdrs.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -7827,19 +10678,21 @@ func (fpus *BFraudParametersUpdatedSubscription) Pipe( }) } -func (b *Bridge) watchFraudParametersUpdated( - sink chan *abi.BridgeFraudParametersUpdated, +func (b *Bridge) watchMovingFundsBelowDustReported( + sink chan *abi.BridgeMovingFundsBelowDustReported, + walletPubKeyHashFilter [][20]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchFraudParametersUpdated( + return b.contract.WatchMovingFundsBelowDustReported( &bind.WatchOpts{Context: ctx}, sink, + walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event FraudParametersUpdated had to be "+ + "subscription to event MovingFundsBelowDustReported had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -7848,7 +10701,7 @@ func (b *Bridge) watchFraudParametersUpdated( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event FraudParametersUpdated failed "+ + "subscription to event MovingFundsBelowDustReported failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -7864,24 +10717,26 @@ func (b *Bridge) watchFraudParametersUpdated( ) } -func (b *Bridge) PastFraudParametersUpdatedEvents( +func (b *Bridge) PastMovingFundsBelowDustReportedEvents( startBlock uint64, endBlock *uint64, -) ([]*abi.BridgeFraudParametersUpdated, error) { - iterator, err := b.contract.FilterFraudParametersUpdated( + walletPubKeyHashFilter [][20]byte, +) ([]*abi.BridgeMovingFundsBelowDustReported, error) { + iterator, err := b.contract.FilterMovingFundsBelowDustReported( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, + walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past FraudParametersUpdated events: [%v]", + "error retrieving past MovingFundsBelowDustReported events: [%v]", err, ) } - events := make([]*abi.BridgeFraudParametersUpdated, 0) + events := make([]*abi.BridgeMovingFundsBelowDustReported, 0) for iterator.Next() { event := iterator.Event @@ -7891,9 +10746,10 @@ func (b *Bridge) PastFraudParametersUpdatedEvents( return events, nil } -func (b *Bridge) GovernanceTransferredEvent( +func (b *Bridge) MovingFundsCommitmentSubmittedEvent( opts *ethereum.SubscribeOpts, -) *BGovernanceTransferredSubscription { + walletPubKeyHashFilter [][20]byte, +) *BMovingFundsCommitmentSubmittedSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -7904,27 +10760,30 @@ func (b *Bridge) GovernanceTransferredEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BGovernanceTransferredSubscription{ + return &BMovingFundsCommitmentSubmittedSubscription{ b, opts, + walletPubKeyHashFilter, } } -type BGovernanceTransferredSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts +type BMovingFundsCommitmentSubmittedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + walletPubKeyHashFilter [][20]byte } -type bridgeGovernanceTransferredFunc func( - OldGovernance common.Address, - NewGovernance common.Address, +type bridgeMovingFundsCommitmentSubmittedFunc func( + WalletPubKeyHash [20]byte, + TargetWallets [][20]byte, + Submitter common.Address, blockNumber uint64, ) -func (gts *BGovernanceTransferredSubscription) OnEvent( - handler bridgeGovernanceTransferredFunc, +func (mfcss *BMovingFundsCommitmentSubmittedSubscription) OnEvent( + handler bridgeMovingFundsCommitmentSubmittedFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeGovernanceTransferred) + eventChan := make(chan *abi.BridgeMovingFundsCommitmentSubmitted) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -7934,50 +10793,52 @@ func (gts *BGovernanceTransferredSubscription) OnEvent( return case event := <-eventChan: handler( - event.OldGovernance, - event.NewGovernance, + event.WalletPubKeyHash, + event.TargetWallets, + event.Submitter, event.Raw.BlockNumber, ) } } }() - sub := gts.Pipe(eventChan) + sub := mfcss.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (gts *BGovernanceTransferredSubscription) Pipe( - sink chan *abi.BridgeGovernanceTransferred, +func (mfcss *BMovingFundsCommitmentSubmittedSubscription) Pipe( + sink chan *abi.BridgeMovingFundsCommitmentSubmitted, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(gts.opts.Tick) + ticker := time.NewTicker(mfcss.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := gts.contract.blockCounter.CurrentBlock() + lastBlock, err := mfcss.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - gts.opts.PastBlocks + fromBlock := lastBlock - mfcss.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past GovernanceTransferred events "+ + "subscription monitoring fetching past MovingFundsCommitmentSubmitted events "+ "starting from block [%v]", fromBlock, ) - events, err := gts.contract.PastGovernanceTransferredEvents( + events, err := mfcss.contract.PastMovingFundsCommitmentSubmittedEvents( fromBlock, nil, + mfcss.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -7987,7 +10848,7 @@ func (gts *BGovernanceTransferredSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past GovernanceTransferred events", + "subscription monitoring fetched [%v] past MovingFundsCommitmentSubmitted events", len(events), ) @@ -7998,8 +10859,9 @@ func (gts *BGovernanceTransferredSubscription) Pipe( } }() - sub := gts.contract.watchGovernanceTransferred( + sub := mfcss.contract.watchMovingFundsCommitmentSubmitted( sink, + mfcss.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -8008,19 +10870,21 @@ func (gts *BGovernanceTransferredSubscription) Pipe( }) } -func (b *Bridge) watchGovernanceTransferred( - sink chan *abi.BridgeGovernanceTransferred, +func (b *Bridge) watchMovingFundsCommitmentSubmitted( + sink chan *abi.BridgeMovingFundsCommitmentSubmitted, + walletPubKeyHashFilter [][20]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchGovernanceTransferred( + return b.contract.WatchMovingFundsCommitmentSubmitted( &bind.WatchOpts{Context: ctx}, sink, + walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event GovernanceTransferred had to be "+ + "subscription to event MovingFundsCommitmentSubmitted had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -8029,7 +10893,7 @@ func (b *Bridge) watchGovernanceTransferred( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event GovernanceTransferred failed "+ + "subscription to event MovingFundsCommitmentSubmitted failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -8045,24 +10909,26 @@ func (b *Bridge) watchGovernanceTransferred( ) } -func (b *Bridge) PastGovernanceTransferredEvents( +func (b *Bridge) PastMovingFundsCommitmentSubmittedEvents( startBlock uint64, endBlock *uint64, -) ([]*abi.BridgeGovernanceTransferred, error) { - iterator, err := b.contract.FilterGovernanceTransferred( + walletPubKeyHashFilter [][20]byte, +) ([]*abi.BridgeMovingFundsCommitmentSubmitted, error) { + iterator, err := b.contract.FilterMovingFundsCommitmentSubmitted( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, + walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past GovernanceTransferred events: [%v]", + "error retrieving past MovingFundsCommitmentSubmitted events: [%v]", err, ) } - events := make([]*abi.BridgeGovernanceTransferred, 0) + events := make([]*abi.BridgeMovingFundsCommitmentSubmitted, 0) for iterator.Next() { event := iterator.Event @@ -8072,9 +10938,10 @@ func (b *Bridge) PastGovernanceTransferredEvents( return events, nil } -func (b *Bridge) InitializedEvent( +func (b *Bridge) MovingFundsCompletedEvent( opts *ethereum.SubscribeOpts, -) *BInitializedSubscription { + walletPubKeyHashFilter [][20]byte, +) *BMovingFundsCompletedSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -8085,26 +10952,29 @@ func (b *Bridge) InitializedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BInitializedSubscription{ + return &BMovingFundsCompletedSubscription{ b, opts, + walletPubKeyHashFilter, } } -type BInitializedSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts +type BMovingFundsCompletedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + walletPubKeyHashFilter [][20]byte } -type bridgeInitializedFunc func( - Version uint8, +type bridgeMovingFundsCompletedFunc func( + WalletPubKeyHash [20]byte, + MovingFundsTxHash [32]byte, blockNumber uint64, ) -func (is *BInitializedSubscription) OnEvent( - handler bridgeInitializedFunc, +func (mfcs *BMovingFundsCompletedSubscription) OnEvent( + handler bridgeMovingFundsCompletedFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeInitialized) + eventChan := make(chan *abi.BridgeMovingFundsCompleted) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -8114,49 +10984,51 @@ func (is *BInitializedSubscription) OnEvent( return case event := <-eventChan: handler( - event.Version, + event.WalletPubKeyHash, + event.MovingFundsTxHash, event.Raw.BlockNumber, ) } } }() - sub := is.Pipe(eventChan) + sub := mfcs.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (is *BInitializedSubscription) Pipe( - sink chan *abi.BridgeInitialized, +func (mfcs *BMovingFundsCompletedSubscription) Pipe( + sink chan *abi.BridgeMovingFundsCompleted, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(is.opts.Tick) + ticker := time.NewTicker(mfcs.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := is.contract.blockCounter.CurrentBlock() + lastBlock, err := mfcs.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - is.opts.PastBlocks + fromBlock := lastBlock - mfcs.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past Initialized events "+ + "subscription monitoring fetching past MovingFundsCompleted events "+ "starting from block [%v]", fromBlock, ) - events, err := is.contract.PastInitializedEvents( + events, err := mfcs.contract.PastMovingFundsCompletedEvents( fromBlock, nil, + mfcs.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -8166,7 +11038,7 @@ func (is *BInitializedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past Initialized events", + "subscription monitoring fetched [%v] past MovingFundsCompleted events", len(events), ) @@ -8177,8 +11049,9 @@ func (is *BInitializedSubscription) Pipe( } }() - sub := is.contract.watchInitialized( + sub := mfcs.contract.watchMovingFundsCompleted( sink, + mfcs.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -8187,19 +11060,21 @@ func (is *BInitializedSubscription) Pipe( }) } -func (b *Bridge) watchInitialized( - sink chan *abi.BridgeInitialized, +func (b *Bridge) watchMovingFundsCompleted( + sink chan *abi.BridgeMovingFundsCompleted, + walletPubKeyHashFilter [][20]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchInitialized( + return b.contract.WatchMovingFundsCompleted( &bind.WatchOpts{Context: ctx}, sink, + walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event Initialized had to be "+ + "subscription to event MovingFundsCompleted had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -8208,7 +11083,7 @@ func (b *Bridge) watchInitialized( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event Initialized failed "+ + "subscription to event MovingFundsCompleted failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -8224,24 +11099,26 @@ func (b *Bridge) watchInitialized( ) } -func (b *Bridge) PastInitializedEvents( +func (b *Bridge) PastMovingFundsCompletedEvents( startBlock uint64, endBlock *uint64, -) ([]*abi.BridgeInitialized, error) { - iterator, err := b.contract.FilterInitialized( + walletPubKeyHashFilter [][20]byte, +) ([]*abi.BridgeMovingFundsCompleted, error) { + iterator, err := b.contract.FilterMovingFundsCompleted( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, + walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past Initialized events: [%v]", + "error retrieving past MovingFundsCompleted events: [%v]", err, ) } - events := make([]*abi.BridgeInitialized, 0) + events := make([]*abi.BridgeMovingFundsCompleted, 0) for iterator.Next() { event := iterator.Event @@ -8251,10 +11128,9 @@ func (b *Bridge) PastInitializedEvents( return events, nil } -func (b *Bridge) MovedFundsSweepTimedOutEvent( +func (b *Bridge) MovingFundsParametersUpdatedEvent( opts *ethereum.SubscribeOpts, - walletPubKeyHashFilter [][20]byte, -) *BMovedFundsSweepTimedOutSubscription { +) *BMovingFundsParametersUpdatedSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -8265,30 +11141,36 @@ func (b *Bridge) MovedFundsSweepTimedOutEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BMovedFundsSweepTimedOutSubscription{ + return &BMovingFundsParametersUpdatedSubscription{ b, opts, - walletPubKeyHashFilter, } } -type BMovedFundsSweepTimedOutSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts - walletPubKeyHashFilter [][20]byte +type BMovingFundsParametersUpdatedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts } -type bridgeMovedFundsSweepTimedOutFunc func( - WalletPubKeyHash [20]byte, - MovingFundsTxHash [32]byte, - MovingFundsTxOutputIndex uint32, +type bridgeMovingFundsParametersUpdatedFunc func( + MovingFundsTxMaxTotalFee uint64, + MovingFundsDustThreshold uint64, + MovingFundsTimeoutResetDelay uint32, + MovingFundsTimeout uint32, + MovingFundsTimeoutSlashingAmount *big.Int, + MovingFundsTimeoutNotifierRewardMultiplier uint32, + MovingFundsCommitmentGasOffset uint16, + MovedFundsSweepTxMaxTotalFee uint64, + MovedFundsSweepTimeout uint32, + MovedFundsSweepTimeoutSlashingAmount *big.Int, + MovedFundsSweepTimeoutNotifierRewardMultiplier uint32, blockNumber uint64, ) -func (mfstos *BMovedFundsSweepTimedOutSubscription) OnEvent( - handler bridgeMovedFundsSweepTimedOutFunc, +func (mfpus *BMovingFundsParametersUpdatedSubscription) OnEvent( + handler bridgeMovingFundsParametersUpdatedFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeMovedFundsSweepTimedOut) + eventChan := make(chan *abi.BridgeMovingFundsParametersUpdated) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -8298,52 +11180,59 @@ func (mfstos *BMovedFundsSweepTimedOutSubscription) OnEvent( return case event := <-eventChan: handler( - event.WalletPubKeyHash, - event.MovingFundsTxHash, - event.MovingFundsTxOutputIndex, + event.MovingFundsTxMaxTotalFee, + event.MovingFundsDustThreshold, + event.MovingFundsTimeoutResetDelay, + event.MovingFundsTimeout, + event.MovingFundsTimeoutSlashingAmount, + event.MovingFundsTimeoutNotifierRewardMultiplier, + event.MovingFundsCommitmentGasOffset, + event.MovedFundsSweepTxMaxTotalFee, + event.MovedFundsSweepTimeout, + event.MovedFundsSweepTimeoutSlashingAmount, + event.MovedFundsSweepTimeoutNotifierRewardMultiplier, event.Raw.BlockNumber, ) } } }() - sub := mfstos.Pipe(eventChan) + sub := mfpus.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (mfstos *BMovedFundsSweepTimedOutSubscription) Pipe( - sink chan *abi.BridgeMovedFundsSweepTimedOut, +func (mfpus *BMovingFundsParametersUpdatedSubscription) Pipe( + sink chan *abi.BridgeMovingFundsParametersUpdated, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(mfstos.opts.Tick) + ticker := time.NewTicker(mfpus.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := mfstos.contract.blockCounter.CurrentBlock() + lastBlock, err := mfpus.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - mfstos.opts.PastBlocks + fromBlock := lastBlock - mfpus.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past MovedFundsSweepTimedOut events "+ + "subscription monitoring fetching past MovingFundsParametersUpdated events "+ "starting from block [%v]", fromBlock, ) - events, err := mfstos.contract.PastMovedFundsSweepTimedOutEvents( + events, err := mfpus.contract.PastMovingFundsParametersUpdatedEvents( fromBlock, nil, - mfstos.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -8353,7 +11242,7 @@ func (mfstos *BMovedFundsSweepTimedOutSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past MovedFundsSweepTimedOut events", + "subscription monitoring fetched [%v] past MovingFundsParametersUpdated events", len(events), ) @@ -8364,9 +11253,8 @@ func (mfstos *BMovedFundsSweepTimedOutSubscription) Pipe( } }() - sub := mfstos.contract.watchMovedFundsSweepTimedOut( + sub := mfpus.contract.watchMovingFundsParametersUpdated( sink, - mfstos.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -8375,21 +11263,19 @@ func (mfstos *BMovedFundsSweepTimedOutSubscription) Pipe( }) } -func (b *Bridge) watchMovedFundsSweepTimedOut( - sink chan *abi.BridgeMovedFundsSweepTimedOut, - walletPubKeyHashFilter [][20]byte, +func (b *Bridge) watchMovingFundsParametersUpdated( + sink chan *abi.BridgeMovingFundsParametersUpdated, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchMovedFundsSweepTimedOut( + return b.contract.WatchMovingFundsParametersUpdated( &bind.WatchOpts{Context: ctx}, sink, - walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event MovedFundsSweepTimedOut had to be "+ + "subscription to event MovingFundsParametersUpdated had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -8398,7 +11284,7 @@ func (b *Bridge) watchMovedFundsSweepTimedOut( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event MovedFundsSweepTimedOut failed "+ + "subscription to event MovingFundsParametersUpdated failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -8414,26 +11300,24 @@ func (b *Bridge) watchMovedFundsSweepTimedOut( ) } -func (b *Bridge) PastMovedFundsSweepTimedOutEvents( +func (b *Bridge) PastMovingFundsParametersUpdatedEvents( startBlock uint64, endBlock *uint64, - walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeMovedFundsSweepTimedOut, error) { - iterator, err := b.contract.FilterMovedFundsSweepTimedOut( +) ([]*abi.BridgeMovingFundsParametersUpdated, error) { + iterator, err := b.contract.FilterMovingFundsParametersUpdated( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, - walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past MovedFundsSweepTimedOut events: [%v]", + "error retrieving past MovingFundsParametersUpdated events: [%v]", err, ) } - events := make([]*abi.BridgeMovedFundsSweepTimedOut, 0) + events := make([]*abi.BridgeMovingFundsParametersUpdated, 0) for iterator.Next() { event := iterator.Event @@ -8443,10 +11327,10 @@ func (b *Bridge) PastMovedFundsSweepTimedOutEvents( return events, nil } -func (b *Bridge) MovedFundsSweptEvent( +func (b *Bridge) MovingFundsTimedOutEvent( opts *ethereum.SubscribeOpts, walletPubKeyHashFilter [][20]byte, -) *BMovedFundsSweptSubscription { +) *BMovingFundsTimedOutSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -8457,29 +11341,28 @@ func (b *Bridge) MovedFundsSweptEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BMovedFundsSweptSubscription{ + return &BMovingFundsTimedOutSubscription{ b, opts, walletPubKeyHashFilter, } } -type BMovedFundsSweptSubscription struct { +type BMovingFundsTimedOutSubscription struct { contract *Bridge opts *ethereum.SubscribeOpts walletPubKeyHashFilter [][20]byte } -type bridgeMovedFundsSweptFunc func( +type bridgeMovingFundsTimedOutFunc func( WalletPubKeyHash [20]byte, - SweepTxHash [32]byte, blockNumber uint64, ) -func (mfss *BMovedFundsSweptSubscription) OnEvent( - handler bridgeMovedFundsSweptFunc, +func (mftos *BMovingFundsTimedOutSubscription) OnEvent( + handler bridgeMovingFundsTimedOutFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeMovedFundsSwept) + eventChan := make(chan *abi.BridgeMovingFundsTimedOut) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -8490,50 +11373,49 @@ func (mfss *BMovedFundsSweptSubscription) OnEvent( case event := <-eventChan: handler( event.WalletPubKeyHash, - event.SweepTxHash, event.Raw.BlockNumber, ) } } }() - sub := mfss.Pipe(eventChan) + sub := mftos.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (mfss *BMovedFundsSweptSubscription) Pipe( - sink chan *abi.BridgeMovedFundsSwept, +func (mftos *BMovingFundsTimedOutSubscription) Pipe( + sink chan *abi.BridgeMovingFundsTimedOut, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(mfss.opts.Tick) + ticker := time.NewTicker(mftos.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := mfss.contract.blockCounter.CurrentBlock() + lastBlock, err := mftos.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - mfss.opts.PastBlocks + fromBlock := lastBlock - mftos.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past MovedFundsSwept events "+ + "subscription monitoring fetching past MovingFundsTimedOut events "+ "starting from block [%v]", fromBlock, ) - events, err := mfss.contract.PastMovedFundsSweptEvents( + events, err := mftos.contract.PastMovingFundsTimedOutEvents( fromBlock, nil, - mfss.walletPubKeyHashFilter, + mftos.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -8543,7 +11425,7 @@ func (mfss *BMovedFundsSweptSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past MovedFundsSwept events", + "subscription monitoring fetched [%v] past MovingFundsTimedOut events", len(events), ) @@ -8554,9 +11436,9 @@ func (mfss *BMovedFundsSweptSubscription) Pipe( } }() - sub := mfss.contract.watchMovedFundsSwept( + sub := mftos.contract.watchMovingFundsTimedOut( sink, - mfss.walletPubKeyHashFilter, + mftos.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -8565,12 +11447,12 @@ func (mfss *BMovedFundsSweptSubscription) Pipe( }) } -func (b *Bridge) watchMovedFundsSwept( - sink chan *abi.BridgeMovedFundsSwept, +func (b *Bridge) watchMovingFundsTimedOut( + sink chan *abi.BridgeMovingFundsTimedOut, walletPubKeyHashFilter [][20]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchMovedFundsSwept( + return b.contract.WatchMovingFundsTimedOut( &bind.WatchOpts{Context: ctx}, sink, walletPubKeyHashFilter, @@ -8579,7 +11461,7 @@ func (b *Bridge) watchMovedFundsSwept( thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event MovedFundsSwept had to be "+ + "subscription to event MovingFundsTimedOut had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -8588,7 +11470,7 @@ func (b *Bridge) watchMovedFundsSwept( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event MovedFundsSwept failed "+ + "subscription to event MovingFundsTimedOut failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -8604,12 +11486,12 @@ func (b *Bridge) watchMovedFundsSwept( ) } -func (b *Bridge) PastMovedFundsSweptEvents( +func (b *Bridge) PastMovingFundsTimedOutEvents( startBlock uint64, endBlock *uint64, walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeMovedFundsSwept, error) { - iterator, err := b.contract.FilterMovedFundsSwept( +) ([]*abi.BridgeMovingFundsTimedOut, error) { + iterator, err := b.contract.FilterMovingFundsTimedOut( &bind.FilterOpts{ Start: startBlock, End: endBlock, @@ -8618,12 +11500,12 @@ func (b *Bridge) PastMovedFundsSweptEvents( ) if err != nil { return nil, fmt.Errorf( - "error retrieving past MovedFundsSwept events: [%v]", + "error retrieving past MovingFundsTimedOut events: [%v]", err, ) } - events := make([]*abi.BridgeMovedFundsSwept, 0) + events := make([]*abi.BridgeMovingFundsTimedOut, 0) for iterator.Next() { event := iterator.Event @@ -8633,10 +11515,10 @@ func (b *Bridge) PastMovedFundsSweptEvents( return events, nil } -func (b *Bridge) MovingFundsBelowDustReportedEvent( +func (b *Bridge) MovingFundsTimeoutResetEvent( opts *ethereum.SubscribeOpts, walletPubKeyHashFilter [][20]byte, -) *BMovingFundsBelowDustReportedSubscription { +) *BMovingFundsTimeoutResetSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -8647,28 +11529,28 @@ func (b *Bridge) MovingFundsBelowDustReportedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BMovingFundsBelowDustReportedSubscription{ + return &BMovingFundsTimeoutResetSubscription{ b, opts, walletPubKeyHashFilter, } } -type BMovingFundsBelowDustReportedSubscription struct { +type BMovingFundsTimeoutResetSubscription struct { contract *Bridge opts *ethereum.SubscribeOpts walletPubKeyHashFilter [][20]byte } -type bridgeMovingFundsBelowDustReportedFunc func( +type bridgeMovingFundsTimeoutResetFunc func( WalletPubKeyHash [20]byte, blockNumber uint64, ) -func (mfbdrs *BMovingFundsBelowDustReportedSubscription) OnEvent( - handler bridgeMovingFundsBelowDustReportedFunc, +func (mftrs *BMovingFundsTimeoutResetSubscription) OnEvent( + handler bridgeMovingFundsTimeoutResetFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeMovingFundsBelowDustReported) + eventChan := make(chan *abi.BridgeMovingFundsTimeoutReset) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -8685,43 +11567,43 @@ func (mfbdrs *BMovingFundsBelowDustReportedSubscription) OnEvent( } }() - sub := mfbdrs.Pipe(eventChan) + sub := mftrs.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (mfbdrs *BMovingFundsBelowDustReportedSubscription) Pipe( - sink chan *abi.BridgeMovingFundsBelowDustReported, +func (mftrs *BMovingFundsTimeoutResetSubscription) Pipe( + sink chan *abi.BridgeMovingFundsTimeoutReset, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(mfbdrs.opts.Tick) + ticker := time.NewTicker(mftrs.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := mfbdrs.contract.blockCounter.CurrentBlock() + lastBlock, err := mftrs.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - mfbdrs.opts.PastBlocks + fromBlock := lastBlock - mftrs.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past MovingFundsBelowDustReported events "+ + "subscription monitoring fetching past MovingFundsTimeoutReset events "+ "starting from block [%v]", fromBlock, ) - events, err := mfbdrs.contract.PastMovingFundsBelowDustReportedEvents( + events, err := mftrs.contract.PastMovingFundsTimeoutResetEvents( fromBlock, nil, - mfbdrs.walletPubKeyHashFilter, + mftrs.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -8731,7 +11613,7 @@ func (mfbdrs *BMovingFundsBelowDustReportedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past MovingFundsBelowDustReported events", + "subscription monitoring fetched [%v] past MovingFundsTimeoutReset events", len(events), ) @@ -8742,9 +11624,9 @@ func (mfbdrs *BMovingFundsBelowDustReportedSubscription) Pipe( } }() - sub := mfbdrs.contract.watchMovingFundsBelowDustReported( + sub := mftrs.contract.watchMovingFundsTimeoutReset( sink, - mfbdrs.walletPubKeyHashFilter, + mftrs.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -8753,12 +11635,12 @@ func (mfbdrs *BMovingFundsBelowDustReportedSubscription) Pipe( }) } -func (b *Bridge) watchMovingFundsBelowDustReported( - sink chan *abi.BridgeMovingFundsBelowDustReported, +func (b *Bridge) watchMovingFundsTimeoutReset( + sink chan *abi.BridgeMovingFundsTimeoutReset, walletPubKeyHashFilter [][20]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchMovingFundsBelowDustReported( + return b.contract.WatchMovingFundsTimeoutReset( &bind.WatchOpts{Context: ctx}, sink, walletPubKeyHashFilter, @@ -8767,7 +11649,7 @@ func (b *Bridge) watchMovingFundsBelowDustReported( thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event MovingFundsBelowDustReported had to be "+ + "subscription to event MovingFundsTimeoutReset had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -8776,7 +11658,7 @@ func (b *Bridge) watchMovingFundsBelowDustReported( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event MovingFundsBelowDustReported failed "+ + "subscription to event MovingFundsTimeoutReset failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -8792,12 +11674,12 @@ func (b *Bridge) watchMovingFundsBelowDustReported( ) } -func (b *Bridge) PastMovingFundsBelowDustReportedEvents( +func (b *Bridge) PastMovingFundsTimeoutResetEvents( startBlock uint64, endBlock *uint64, walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeMovingFundsBelowDustReported, error) { - iterator, err := b.contract.FilterMovingFundsBelowDustReported( +) ([]*abi.BridgeMovingFundsTimeoutReset, error) { + iterator, err := b.contract.FilterMovingFundsTimeoutReset( &bind.FilterOpts{ Start: startBlock, End: endBlock, @@ -8806,12 +11688,12 @@ func (b *Bridge) PastMovingFundsBelowDustReportedEvents( ) if err != nil { return nil, fmt.Errorf( - "error retrieving past MovingFundsBelowDustReported events: [%v]", + "error retrieving past MovingFundsTimeoutReset events: [%v]", err, ) } - events := make([]*abi.BridgeMovingFundsBelowDustReported, 0) + events := make([]*abi.BridgeMovingFundsTimeoutReset, 0) for iterator.Next() { event := iterator.Event @@ -8821,10 +11703,12 @@ func (b *Bridge) PastMovingFundsBelowDustReportedEvents( return events, nil } -func (b *Bridge) MovingFundsCommitmentSubmittedEvent( +func (b *Bridge) NewFrostWalletRegisteredEvent( opts *ethereum.SubscribeOpts, + walletIDFilter [][32]byte, walletPubKeyHashFilter [][20]byte, -) *BMovingFundsCommitmentSubmittedSubscription { + xOnlyOutputKeyFilter [][32]byte, +) *BNewFrostWalletRegisteredSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -8835,30 +11719,34 @@ func (b *Bridge) MovingFundsCommitmentSubmittedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BMovingFundsCommitmentSubmittedSubscription{ + return &BNewFrostWalletRegisteredSubscription{ b, opts, + walletIDFilter, walletPubKeyHashFilter, + xOnlyOutputKeyFilter, } } -type BMovingFundsCommitmentSubmittedSubscription struct { +type BNewFrostWalletRegisteredSubscription struct { contract *Bridge opts *ethereum.SubscribeOpts + walletIDFilter [][32]byte walletPubKeyHashFilter [][20]byte + xOnlyOutputKeyFilter [][32]byte } -type bridgeMovingFundsCommitmentSubmittedFunc func( +type bridgeNewFrostWalletRegisteredFunc func( + WalletID [32]byte, WalletPubKeyHash [20]byte, - TargetWallets [][20]byte, - Submitter common.Address, + XOnlyOutputKey [32]byte, blockNumber uint64, ) -func (mfcss *BMovingFundsCommitmentSubmittedSubscription) OnEvent( - handler bridgeMovingFundsCommitmentSubmittedFunc, +func (nfwrs *BNewFrostWalletRegisteredSubscription) OnEvent( + handler bridgeNewFrostWalletRegisteredFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeMovingFundsCommitmentSubmitted) + eventChan := make(chan *abi.BridgeNewFrostWalletRegistered) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -8868,52 +11756,54 @@ func (mfcss *BMovingFundsCommitmentSubmittedSubscription) OnEvent( return case event := <-eventChan: handler( + event.WalletID, event.WalletPubKeyHash, - event.TargetWallets, - event.Submitter, + event.XOnlyOutputKey, event.Raw.BlockNumber, ) } } }() - sub := mfcss.Pipe(eventChan) + sub := nfwrs.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (mfcss *BMovingFundsCommitmentSubmittedSubscription) Pipe( - sink chan *abi.BridgeMovingFundsCommitmentSubmitted, +func (nfwrs *BNewFrostWalletRegisteredSubscription) Pipe( + sink chan *abi.BridgeNewFrostWalletRegistered, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(mfcss.opts.Tick) + ticker := time.NewTicker(nfwrs.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := mfcss.contract.blockCounter.CurrentBlock() + lastBlock, err := nfwrs.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - mfcss.opts.PastBlocks + fromBlock := lastBlock - nfwrs.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past MovingFundsCommitmentSubmitted events "+ + "subscription monitoring fetching past NewFrostWalletRegistered events "+ "starting from block [%v]", fromBlock, ) - events, err := mfcss.contract.PastMovingFundsCommitmentSubmittedEvents( + events, err := nfwrs.contract.PastNewFrostWalletRegisteredEvents( fromBlock, nil, - mfcss.walletPubKeyHashFilter, + nfwrs.walletIDFilter, + nfwrs.walletPubKeyHashFilter, + nfwrs.xOnlyOutputKeyFilter, ) if err != nil { bLogger.Errorf( @@ -8923,7 +11813,7 @@ func (mfcss *BMovingFundsCommitmentSubmittedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past MovingFundsCommitmentSubmitted events", + "subscription monitoring fetched [%v] past NewFrostWalletRegistered events", len(events), ) @@ -8934,9 +11824,11 @@ func (mfcss *BMovingFundsCommitmentSubmittedSubscription) Pipe( } }() - sub := mfcss.contract.watchMovingFundsCommitmentSubmitted( + sub := nfwrs.contract.watchNewFrostWalletRegistered( sink, - mfcss.walletPubKeyHashFilter, + nfwrs.walletIDFilter, + nfwrs.walletPubKeyHashFilter, + nfwrs.xOnlyOutputKeyFilter, ) return subscription.NewEventSubscription(func() { @@ -8945,21 +11837,25 @@ func (mfcss *BMovingFundsCommitmentSubmittedSubscription) Pipe( }) } -func (b *Bridge) watchMovingFundsCommitmentSubmitted( - sink chan *abi.BridgeMovingFundsCommitmentSubmitted, +func (b *Bridge) watchNewFrostWalletRegistered( + sink chan *abi.BridgeNewFrostWalletRegistered, + walletIDFilter [][32]byte, walletPubKeyHashFilter [][20]byte, + xOnlyOutputKeyFilter [][32]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchMovingFundsCommitmentSubmitted( + return b.contract.WatchNewFrostWalletRegistered( &bind.WatchOpts{Context: ctx}, sink, + walletIDFilter, walletPubKeyHashFilter, + xOnlyOutputKeyFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event MovingFundsCommitmentSubmitted had to be "+ + "subscription to event NewFrostWalletRegistered had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -8968,7 +11864,7 @@ func (b *Bridge) watchMovingFundsCommitmentSubmitted( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event MovingFundsCommitmentSubmitted failed "+ + "subscription to event NewFrostWalletRegistered failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -8984,26 +11880,30 @@ func (b *Bridge) watchMovingFundsCommitmentSubmitted( ) } -func (b *Bridge) PastMovingFundsCommitmentSubmittedEvents( +func (b *Bridge) PastNewFrostWalletRegisteredEvents( startBlock uint64, endBlock *uint64, + walletIDFilter [][32]byte, walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeMovingFundsCommitmentSubmitted, error) { - iterator, err := b.contract.FilterMovingFundsCommitmentSubmitted( + xOnlyOutputKeyFilter [][32]byte, +) ([]*abi.BridgeNewFrostWalletRegistered, error) { + iterator, err := b.contract.FilterNewFrostWalletRegistered( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, + walletIDFilter, walletPubKeyHashFilter, + xOnlyOutputKeyFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past MovingFundsCommitmentSubmitted events: [%v]", + "error retrieving past NewFrostWalletRegistered events: [%v]", err, ) } - events := make([]*abi.BridgeMovingFundsCommitmentSubmitted, 0) + events := make([]*abi.BridgeNewFrostWalletRegistered, 0) for iterator.Next() { event := iterator.Event @@ -9013,10 +11913,11 @@ func (b *Bridge) PastMovingFundsCommitmentSubmittedEvents( return events, nil } -func (b *Bridge) MovingFundsCompletedEvent( +func (b *Bridge) NewWalletRegisteredEvent( opts *ethereum.SubscribeOpts, + ecdsaWalletIDFilter [][32]byte, walletPubKeyHashFilter [][20]byte, -) *BMovingFundsCompletedSubscription { +) *BNewWalletRegisteredSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -9027,29 +11928,31 @@ func (b *Bridge) MovingFundsCompletedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BMovingFundsCompletedSubscription{ + return &BNewWalletRegisteredSubscription{ b, opts, + ecdsaWalletIDFilter, walletPubKeyHashFilter, } } -type BMovingFundsCompletedSubscription struct { +type BNewWalletRegisteredSubscription struct { contract *Bridge opts *ethereum.SubscribeOpts + ecdsaWalletIDFilter [][32]byte walletPubKeyHashFilter [][20]byte } -type bridgeMovingFundsCompletedFunc func( +type bridgeNewWalletRegisteredFunc func( + EcdsaWalletID [32]byte, WalletPubKeyHash [20]byte, - MovingFundsTxHash [32]byte, blockNumber uint64, ) -func (mfcs *BMovingFundsCompletedSubscription) OnEvent( - handler bridgeMovingFundsCompletedFunc, +func (nwrs *BNewWalletRegisteredSubscription) OnEvent( + handler bridgeNewWalletRegisteredFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeMovingFundsCompleted) + eventChan := make(chan *abi.BridgeNewWalletRegistered) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -9059,51 +11962,52 @@ func (mfcs *BMovingFundsCompletedSubscription) OnEvent( return case event := <-eventChan: handler( + event.EcdsaWalletID, event.WalletPubKeyHash, - event.MovingFundsTxHash, event.Raw.BlockNumber, ) } } }() - sub := mfcs.Pipe(eventChan) + sub := nwrs.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (mfcs *BMovingFundsCompletedSubscription) Pipe( - sink chan *abi.BridgeMovingFundsCompleted, +func (nwrs *BNewWalletRegisteredSubscription) Pipe( + sink chan *abi.BridgeNewWalletRegistered, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(mfcs.opts.Tick) + ticker := time.NewTicker(nwrs.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := mfcs.contract.blockCounter.CurrentBlock() + lastBlock, err := nwrs.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - mfcs.opts.PastBlocks + fromBlock := lastBlock - nwrs.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past MovingFundsCompleted events "+ + "subscription monitoring fetching past NewWalletRegistered events "+ "starting from block [%v]", fromBlock, ) - events, err := mfcs.contract.PastMovingFundsCompletedEvents( + events, err := nwrs.contract.PastNewWalletRegisteredEvents( fromBlock, nil, - mfcs.walletPubKeyHashFilter, + nwrs.ecdsaWalletIDFilter, + nwrs.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -9113,7 +12017,7 @@ func (mfcs *BMovingFundsCompletedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past MovingFundsCompleted events", + "subscription monitoring fetched [%v] past NewWalletRegistered events", len(events), ) @@ -9124,9 +12028,10 @@ func (mfcs *BMovingFundsCompletedSubscription) Pipe( } }() - sub := mfcs.contract.watchMovingFundsCompleted( + sub := nwrs.contract.watchNewWalletRegistered( sink, - mfcs.walletPubKeyHashFilter, + nwrs.ecdsaWalletIDFilter, + nwrs.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -9135,21 +12040,23 @@ func (mfcs *BMovingFundsCompletedSubscription) Pipe( }) } -func (b *Bridge) watchMovingFundsCompleted( - sink chan *abi.BridgeMovingFundsCompleted, +func (b *Bridge) watchNewWalletRegistered( + sink chan *abi.BridgeNewWalletRegistered, + ecdsaWalletIDFilter [][32]byte, walletPubKeyHashFilter [][20]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchMovingFundsCompleted( + return b.contract.WatchNewWalletRegistered( &bind.WatchOpts{Context: ctx}, sink, + ecdsaWalletIDFilter, walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event MovingFundsCompleted had to be "+ + "subscription to event NewWalletRegistered had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -9158,7 +12065,7 @@ func (b *Bridge) watchMovingFundsCompleted( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event MovingFundsCompleted failed "+ + "subscription to event NewWalletRegistered failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -9174,26 +12081,28 @@ func (b *Bridge) watchMovingFundsCompleted( ) } -func (b *Bridge) PastMovingFundsCompletedEvents( +func (b *Bridge) PastNewWalletRegisteredEvents( startBlock uint64, endBlock *uint64, + ecdsaWalletIDFilter [][32]byte, walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeMovingFundsCompleted, error) { - iterator, err := b.contract.FilterMovingFundsCompleted( +) ([]*abi.BridgeNewWalletRegistered, error) { + iterator, err := b.contract.FilterNewWalletRegistered( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, + ecdsaWalletIDFilter, walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past MovingFundsCompleted events: [%v]", + "error retrieving past NewWalletRegistered events: [%v]", err, ) } - events := make([]*abi.BridgeMovingFundsCompleted, 0) + events := make([]*abi.BridgeNewWalletRegistered, 0) for iterator.Next() { event := iterator.Event @@ -9203,9 +12112,12 @@ func (b *Bridge) PastMovingFundsCompletedEvents( return events, nil } -func (b *Bridge) MovingFundsParametersUpdatedEvent( +func (b *Bridge) NewWalletRegisteredV2Event( opts *ethereum.SubscribeOpts, -) *BMovingFundsParametersUpdatedSubscription { + walletIDFilter [][32]byte, + ecdsaWalletIDFilter [][32]byte, + walletPubKeyHashFilter [][20]byte, +) *BNewWalletRegisteredV2Subscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -9216,36 +12128,34 @@ func (b *Bridge) MovingFundsParametersUpdatedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BMovingFundsParametersUpdatedSubscription{ + return &BNewWalletRegisteredV2Subscription{ b, opts, + walletIDFilter, + ecdsaWalletIDFilter, + walletPubKeyHashFilter, } } -type BMovingFundsParametersUpdatedSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts +type BNewWalletRegisteredV2Subscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + walletIDFilter [][32]byte + ecdsaWalletIDFilter [][32]byte + walletPubKeyHashFilter [][20]byte } -type bridgeMovingFundsParametersUpdatedFunc func( - MovingFundsTxMaxTotalFee uint64, - MovingFundsDustThreshold uint64, - MovingFundsTimeoutResetDelay uint32, - MovingFundsTimeout uint32, - MovingFundsTimeoutSlashingAmount *big.Int, - MovingFundsTimeoutNotifierRewardMultiplier uint32, - MovingFundsCommitmentGasOffset uint16, - MovedFundsSweepTxMaxTotalFee uint64, - MovedFundsSweepTimeout uint32, - MovedFundsSweepTimeoutSlashingAmount *big.Int, - MovedFundsSweepTimeoutNotifierRewardMultiplier uint32, +type bridgeNewWalletRegisteredV2Func func( + WalletID [32]byte, + EcdsaWalletID [32]byte, + WalletPubKeyHash [20]byte, blockNumber uint64, ) -func (mfpus *BMovingFundsParametersUpdatedSubscription) OnEvent( - handler bridgeMovingFundsParametersUpdatedFunc, +func (nwrvs *BNewWalletRegisteredV2Subscription) OnEvent( + handler bridgeNewWalletRegisteredV2Func, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeMovingFundsParametersUpdated) + eventChan := make(chan *abi.BridgeNewWalletRegisteredV2) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -9255,59 +12165,54 @@ func (mfpus *BMovingFundsParametersUpdatedSubscription) OnEvent( return case event := <-eventChan: handler( - event.MovingFundsTxMaxTotalFee, - event.MovingFundsDustThreshold, - event.MovingFundsTimeoutResetDelay, - event.MovingFundsTimeout, - event.MovingFundsTimeoutSlashingAmount, - event.MovingFundsTimeoutNotifierRewardMultiplier, - event.MovingFundsCommitmentGasOffset, - event.MovedFundsSweepTxMaxTotalFee, - event.MovedFundsSweepTimeout, - event.MovedFundsSweepTimeoutSlashingAmount, - event.MovedFundsSweepTimeoutNotifierRewardMultiplier, + event.WalletID, + event.EcdsaWalletID, + event.WalletPubKeyHash, event.Raw.BlockNumber, ) } } }() - sub := mfpus.Pipe(eventChan) + sub := nwrvs.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (mfpus *BMovingFundsParametersUpdatedSubscription) Pipe( - sink chan *abi.BridgeMovingFundsParametersUpdated, +func (nwrvs *BNewWalletRegisteredV2Subscription) Pipe( + sink chan *abi.BridgeNewWalletRegisteredV2, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(mfpus.opts.Tick) + ticker := time.NewTicker(nwrvs.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := mfpus.contract.blockCounter.CurrentBlock() + lastBlock, err := nwrvs.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - mfpus.opts.PastBlocks + fromBlock := lastBlock - nwrvs.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past MovingFundsParametersUpdated events "+ + "subscription monitoring fetching past NewWalletRegisteredV2 events "+ "starting from block [%v]", fromBlock, ) - events, err := mfpus.contract.PastMovingFundsParametersUpdatedEvents( + events, err := nwrvs.contract.PastNewWalletRegisteredV2Events( fromBlock, nil, + nwrvs.walletIDFilter, + nwrvs.ecdsaWalletIDFilter, + nwrvs.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -9317,7 +12222,7 @@ func (mfpus *BMovingFundsParametersUpdatedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past MovingFundsParametersUpdated events", + "subscription monitoring fetched [%v] past NewWalletRegisteredV2 events", len(events), ) @@ -9328,8 +12233,11 @@ func (mfpus *BMovingFundsParametersUpdatedSubscription) Pipe( } }() - sub := mfpus.contract.watchMovingFundsParametersUpdated( + sub := nwrvs.contract.watchNewWalletRegisteredV2( sink, + nwrvs.walletIDFilter, + nwrvs.ecdsaWalletIDFilter, + nwrvs.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -9338,19 +12246,25 @@ func (mfpus *BMovingFundsParametersUpdatedSubscription) Pipe( }) } -func (b *Bridge) watchMovingFundsParametersUpdated( - sink chan *abi.BridgeMovingFundsParametersUpdated, +func (b *Bridge) watchNewWalletRegisteredV2( + sink chan *abi.BridgeNewWalletRegisteredV2, + walletIDFilter [][32]byte, + ecdsaWalletIDFilter [][32]byte, + walletPubKeyHashFilter [][20]byte, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchMovingFundsParametersUpdated( + return b.contract.WatchNewWalletRegisteredV2( &bind.WatchOpts{Context: ctx}, sink, + walletIDFilter, + ecdsaWalletIDFilter, + walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event MovingFundsParametersUpdated had to be "+ + "subscription to event NewWalletRegisteredV2 had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -9359,7 +12273,7 @@ func (b *Bridge) watchMovingFundsParametersUpdated( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event MovingFundsParametersUpdated failed "+ + "subscription to event NewWalletRegisteredV2 failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -9375,24 +12289,30 @@ func (b *Bridge) watchMovingFundsParametersUpdated( ) } -func (b *Bridge) PastMovingFundsParametersUpdatedEvents( +func (b *Bridge) PastNewWalletRegisteredV2Events( startBlock uint64, endBlock *uint64, -) ([]*abi.BridgeMovingFundsParametersUpdated, error) { - iterator, err := b.contract.FilterMovingFundsParametersUpdated( + walletIDFilter [][32]byte, + ecdsaWalletIDFilter [][32]byte, + walletPubKeyHashFilter [][20]byte, +) ([]*abi.BridgeNewWalletRegisteredV2, error) { + iterator, err := b.contract.FilterNewWalletRegisteredV2( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, + walletIDFilter, + ecdsaWalletIDFilter, + walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past MovingFundsParametersUpdated events: [%v]", + "error retrieving past NewWalletRegisteredV2 events: [%v]", err, ) } - events := make([]*abi.BridgeMovingFundsParametersUpdated, 0) + events := make([]*abi.BridgeNewWalletRegisteredV2, 0) for iterator.Next() { event := iterator.Event @@ -9402,10 +12322,9 @@ func (b *Bridge) PastMovingFundsParametersUpdatedEvents( return events, nil } -func (b *Bridge) MovingFundsTimedOutEvent( +func (b *Bridge) NewWalletRequestedEvent( opts *ethereum.SubscribeOpts, - walletPubKeyHashFilter [][20]byte, -) *BMovingFundsTimedOutSubscription { +) *BNewWalletRequestedSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -9416,28 +12335,25 @@ func (b *Bridge) MovingFundsTimedOutEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BMovingFundsTimedOutSubscription{ + return &BNewWalletRequestedSubscription{ b, opts, - walletPubKeyHashFilter, } } -type BMovingFundsTimedOutSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts - walletPubKeyHashFilter [][20]byte +type BNewWalletRequestedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts } -type bridgeMovingFundsTimedOutFunc func( - WalletPubKeyHash [20]byte, +type bridgeNewWalletRequestedFunc func( blockNumber uint64, ) -func (mftos *BMovingFundsTimedOutSubscription) OnEvent( - handler bridgeMovingFundsTimedOutFunc, +func (nwrs *BNewWalletRequestedSubscription) OnEvent( + handler bridgeNewWalletRequestedFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeMovingFundsTimedOut) + eventChan := make(chan *abi.BridgeNewWalletRequested) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -9447,50 +12363,48 @@ func (mftos *BMovingFundsTimedOutSubscription) OnEvent( return case event := <-eventChan: handler( - event.WalletPubKeyHash, event.Raw.BlockNumber, ) } } }() - sub := mftos.Pipe(eventChan) + sub := nwrs.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (mftos *BMovingFundsTimedOutSubscription) Pipe( - sink chan *abi.BridgeMovingFundsTimedOut, +func (nwrs *BNewWalletRequestedSubscription) Pipe( + sink chan *abi.BridgeNewWalletRequested, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(mftos.opts.Tick) + ticker := time.NewTicker(nwrs.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := mftos.contract.blockCounter.CurrentBlock() + lastBlock, err := nwrs.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - mftos.opts.PastBlocks + fromBlock := lastBlock - nwrs.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past MovingFundsTimedOut events "+ + "subscription monitoring fetching past NewWalletRequested events "+ "starting from block [%v]", fromBlock, ) - events, err := mftos.contract.PastMovingFundsTimedOutEvents( + events, err := nwrs.contract.PastNewWalletRequestedEvents( fromBlock, nil, - mftos.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -9500,7 +12414,7 @@ func (mftos *BMovingFundsTimedOutSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past MovingFundsTimedOut events", + "subscription monitoring fetched [%v] past NewWalletRequested events", len(events), ) @@ -9511,9 +12425,8 @@ func (mftos *BMovingFundsTimedOutSubscription) Pipe( } }() - sub := mftos.contract.watchMovingFundsTimedOut( + sub := nwrs.contract.watchNewWalletRequested( sink, - mftos.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -9522,21 +12435,19 @@ func (mftos *BMovingFundsTimedOutSubscription) Pipe( }) } -func (b *Bridge) watchMovingFundsTimedOut( - sink chan *abi.BridgeMovingFundsTimedOut, - walletPubKeyHashFilter [][20]byte, +func (b *Bridge) watchNewWalletRequested( + sink chan *abi.BridgeNewWalletRequested, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchMovingFundsTimedOut( + return b.contract.WatchNewWalletRequested( &bind.WatchOpts{Context: ctx}, sink, - walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event MovingFundsTimedOut had to be "+ + "subscription to event NewWalletRequested had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -9545,7 +12456,7 @@ func (b *Bridge) watchMovingFundsTimedOut( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event MovingFundsTimedOut failed "+ + "subscription to event NewWalletRequested failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -9561,26 +12472,24 @@ func (b *Bridge) watchMovingFundsTimedOut( ) } -func (b *Bridge) PastMovingFundsTimedOutEvents( +func (b *Bridge) PastNewWalletRequestedEvents( startBlock uint64, endBlock *uint64, - walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeMovingFundsTimedOut, error) { - iterator, err := b.contract.FilterMovingFundsTimedOut( +) ([]*abi.BridgeNewWalletRequested, error) { + iterator, err := b.contract.FilterNewWalletRequested( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, - walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past MovingFundsTimedOut events: [%v]", + "error retrieving past NewWalletRequested events: [%v]", err, ) } - events := make([]*abi.BridgeMovingFundsTimedOut, 0) + events := make([]*abi.BridgeNewWalletRequested, 0) for iterator.Next() { event := iterator.Event @@ -9590,10 +12499,10 @@ func (b *Bridge) PastMovingFundsTimedOutEvents( return events, nil } -func (b *Bridge) MovingFundsTimeoutResetEvent( +func (b *Bridge) NewWalletSchemeSetEvent( opts *ethereum.SubscribeOpts, - walletPubKeyHashFilter [][20]byte, -) *BMovingFundsTimeoutResetSubscription { + schemeFilter []uint8, +) *BNewWalletSchemeSetSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -9604,28 +12513,28 @@ func (b *Bridge) MovingFundsTimeoutResetEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BMovingFundsTimeoutResetSubscription{ + return &BNewWalletSchemeSetSubscription{ b, opts, - walletPubKeyHashFilter, + schemeFilter, } } -type BMovingFundsTimeoutResetSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts - walletPubKeyHashFilter [][20]byte +type BNewWalletSchemeSetSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + schemeFilter []uint8 } -type bridgeMovingFundsTimeoutResetFunc func( - WalletPubKeyHash [20]byte, +type bridgeNewWalletSchemeSetFunc func( + Scheme uint8, blockNumber uint64, ) -func (mftrs *BMovingFundsTimeoutResetSubscription) OnEvent( - handler bridgeMovingFundsTimeoutResetFunc, +func (nwsss *BNewWalletSchemeSetSubscription) OnEvent( + handler bridgeNewWalletSchemeSetFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeMovingFundsTimeoutReset) + eventChan := make(chan *abi.BridgeNewWalletSchemeSet) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -9635,50 +12544,50 @@ func (mftrs *BMovingFundsTimeoutResetSubscription) OnEvent( return case event := <-eventChan: handler( - event.WalletPubKeyHash, + event.Scheme, event.Raw.BlockNumber, ) } } }() - sub := mftrs.Pipe(eventChan) + sub := nwsss.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (mftrs *BMovingFundsTimeoutResetSubscription) Pipe( - sink chan *abi.BridgeMovingFundsTimeoutReset, +func (nwsss *BNewWalletSchemeSetSubscription) Pipe( + sink chan *abi.BridgeNewWalletSchemeSet, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(mftrs.opts.Tick) + ticker := time.NewTicker(nwsss.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := mftrs.contract.blockCounter.CurrentBlock() + lastBlock, err := nwsss.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - mftrs.opts.PastBlocks + fromBlock := lastBlock - nwsss.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past MovingFundsTimeoutReset events "+ + "subscription monitoring fetching past NewWalletSchemeSet events "+ "starting from block [%v]", fromBlock, ) - events, err := mftrs.contract.PastMovingFundsTimeoutResetEvents( + events, err := nwsss.contract.PastNewWalletSchemeSetEvents( fromBlock, nil, - mftrs.walletPubKeyHashFilter, + nwsss.schemeFilter, ) if err != nil { bLogger.Errorf( @@ -9688,7 +12597,7 @@ func (mftrs *BMovingFundsTimeoutResetSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past MovingFundsTimeoutReset events", + "subscription monitoring fetched [%v] past NewWalletSchemeSet events", len(events), ) @@ -9699,9 +12608,9 @@ func (mftrs *BMovingFundsTimeoutResetSubscription) Pipe( } }() - sub := mftrs.contract.watchMovingFundsTimeoutReset( + sub := nwsss.contract.watchNewWalletSchemeSet( sink, - mftrs.walletPubKeyHashFilter, + nwsss.schemeFilter, ) return subscription.NewEventSubscription(func() { @@ -9710,21 +12619,21 @@ func (mftrs *BMovingFundsTimeoutResetSubscription) Pipe( }) } -func (b *Bridge) watchMovingFundsTimeoutReset( - sink chan *abi.BridgeMovingFundsTimeoutReset, - walletPubKeyHashFilter [][20]byte, +func (b *Bridge) watchNewWalletSchemeSet( + sink chan *abi.BridgeNewWalletSchemeSet, + schemeFilter []uint8, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchMovingFundsTimeoutReset( + return b.contract.WatchNewWalletSchemeSet( &bind.WatchOpts{Context: ctx}, sink, - walletPubKeyHashFilter, + schemeFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event MovingFundsTimeoutReset had to be "+ + "subscription to event NewWalletSchemeSet had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -9733,7 +12642,7 @@ func (b *Bridge) watchMovingFundsTimeoutReset( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event MovingFundsTimeoutReset failed "+ + "subscription to event NewWalletSchemeSet failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -9749,26 +12658,26 @@ func (b *Bridge) watchMovingFundsTimeoutReset( ) } -func (b *Bridge) PastMovingFundsTimeoutResetEvents( +func (b *Bridge) PastNewWalletSchemeSetEvents( startBlock uint64, endBlock *uint64, - walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeMovingFundsTimeoutReset, error) { - iterator, err := b.contract.FilterMovingFundsTimeoutReset( + schemeFilter []uint8, +) ([]*abi.BridgeNewWalletSchemeSet, error) { + iterator, err := b.contract.FilterNewWalletSchemeSet( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, - walletPubKeyHashFilter, + schemeFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past MovingFundsTimeoutReset events: [%v]", + "error retrieving past NewWalletSchemeSet events: [%v]", err, ) } - events := make([]*abi.BridgeMovingFundsTimeoutReset, 0) + events := make([]*abi.BridgeNewWalletSchemeSet, 0) for iterator.Next() { event := iterator.Event @@ -9778,11 +12687,9 @@ func (b *Bridge) PastMovingFundsTimeoutResetEvents( return events, nil } -func (b *Bridge) NewWalletRegisteredEvent( +func (b *Bridge) P2TRFraudRouterSetEvent( opts *ethereum.SubscribeOpts, - ecdsaWalletIDFilter [][32]byte, - walletPubKeyHashFilter [][20]byte, -) *BNewWalletRegisteredSubscription { +) *BP2TRFraudRouterSetSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -9793,31 +12700,26 @@ func (b *Bridge) NewWalletRegisteredEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BNewWalletRegisteredSubscription{ + return &BP2TRFraudRouterSetSubscription{ b, opts, - ecdsaWalletIDFilter, - walletPubKeyHashFilter, } } -type BNewWalletRegisteredSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts - ecdsaWalletIDFilter [][32]byte - walletPubKeyHashFilter [][20]byte +type BP2TRFraudRouterSetSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts } -type bridgeNewWalletRegisteredFunc func( - EcdsaWalletID [32]byte, - WalletPubKeyHash [20]byte, +type bridgeP2TRFraudRouterSetFunc func( + P2trFraudRouter common.Address, blockNumber uint64, ) -func (nwrs *BNewWalletRegisteredSubscription) OnEvent( - handler bridgeNewWalletRegisteredFunc, +func (ptrfrss *BP2TRFraudRouterSetSubscription) OnEvent( + handler bridgeP2TRFraudRouterSetFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeNewWalletRegistered) + eventChan := make(chan *abi.BridgeP2TRFraudRouterSet) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -9827,52 +12729,49 @@ func (nwrs *BNewWalletRegisteredSubscription) OnEvent( return case event := <-eventChan: handler( - event.EcdsaWalletID, - event.WalletPubKeyHash, + event.P2trFraudRouter, event.Raw.BlockNumber, ) } } }() - sub := nwrs.Pipe(eventChan) + sub := ptrfrss.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (nwrs *BNewWalletRegisteredSubscription) Pipe( - sink chan *abi.BridgeNewWalletRegistered, +func (ptrfrss *BP2TRFraudRouterSetSubscription) Pipe( + sink chan *abi.BridgeP2TRFraudRouterSet, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(nwrs.opts.Tick) + ticker := time.NewTicker(ptrfrss.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := nwrs.contract.blockCounter.CurrentBlock() + lastBlock, err := ptrfrss.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - nwrs.opts.PastBlocks + fromBlock := lastBlock - ptrfrss.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past NewWalletRegistered events "+ + "subscription monitoring fetching past P2TRFraudRouterSet events "+ "starting from block [%v]", fromBlock, ) - events, err := nwrs.contract.PastNewWalletRegisteredEvents( + events, err := ptrfrss.contract.PastP2TRFraudRouterSetEvents( fromBlock, nil, - nwrs.ecdsaWalletIDFilter, - nwrs.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -9882,7 +12781,7 @@ func (nwrs *BNewWalletRegisteredSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past NewWalletRegistered events", + "subscription monitoring fetched [%v] past P2TRFraudRouterSet events", len(events), ) @@ -9893,10 +12792,8 @@ func (nwrs *BNewWalletRegisteredSubscription) Pipe( } }() - sub := nwrs.contract.watchNewWalletRegistered( + sub := ptrfrss.contract.watchP2TRFraudRouterSet( sink, - nwrs.ecdsaWalletIDFilter, - nwrs.walletPubKeyHashFilter, ) return subscription.NewEventSubscription(func() { @@ -9905,23 +12802,19 @@ func (nwrs *BNewWalletRegisteredSubscription) Pipe( }) } -func (b *Bridge) watchNewWalletRegistered( - sink chan *abi.BridgeNewWalletRegistered, - ecdsaWalletIDFilter [][32]byte, - walletPubKeyHashFilter [][20]byte, +func (b *Bridge) watchP2TRFraudRouterSet( + sink chan *abi.BridgeP2TRFraudRouterSet, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchNewWalletRegistered( + return b.contract.WatchP2TRFraudRouterSet( &bind.WatchOpts{Context: ctx}, sink, - ecdsaWalletIDFilter, - walletPubKeyHashFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event NewWalletRegistered had to be "+ + "subscription to event P2TRFraudRouterSet had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -9930,7 +12823,7 @@ func (b *Bridge) watchNewWalletRegistered( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event NewWalletRegistered failed "+ + "subscription to event P2TRFraudRouterSet failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -9946,28 +12839,24 @@ func (b *Bridge) watchNewWalletRegistered( ) } -func (b *Bridge) PastNewWalletRegisteredEvents( +func (b *Bridge) PastP2TRFraudRouterSetEvents( startBlock uint64, endBlock *uint64, - ecdsaWalletIDFilter [][32]byte, - walletPubKeyHashFilter [][20]byte, -) ([]*abi.BridgeNewWalletRegistered, error) { - iterator, err := b.contract.FilterNewWalletRegistered( +) ([]*abi.BridgeP2TRFraudRouterSet, error) { + iterator, err := b.contract.FilterP2TRFraudRouterSet( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, - ecdsaWalletIDFilter, - walletPubKeyHashFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past NewWalletRegistered events: [%v]", + "error retrieving past P2TRFraudRouterSet events: [%v]", err, ) } - events := make([]*abi.BridgeNewWalletRegistered, 0) + events := make([]*abi.BridgeP2TRFraudRouterSet, 0) for iterator.Next() { event := iterator.Event @@ -9977,9 +12866,9 @@ func (b *Bridge) PastNewWalletRegisteredEvents( return events, nil } -func (b *Bridge) NewWalletRequestedEvent( +func (b *Bridge) RebateStakingSetEvent( opts *ethereum.SubscribeOpts, -) *BNewWalletRequestedSubscription { +) *BRebateStakingSetSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -9990,25 +12879,26 @@ func (b *Bridge) NewWalletRequestedEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BNewWalletRequestedSubscription{ + return &BRebateStakingSetSubscription{ b, opts, } } -type BNewWalletRequestedSubscription struct { +type BRebateStakingSetSubscription struct { contract *Bridge opts *ethereum.SubscribeOpts } -type bridgeNewWalletRequestedFunc func( +type bridgeRebateStakingSetFunc func( + RebateStaking common.Address, blockNumber uint64, ) -func (nwrs *BNewWalletRequestedSubscription) OnEvent( - handler bridgeNewWalletRequestedFunc, +func (rsss *BRebateStakingSetSubscription) OnEvent( + handler bridgeRebateStakingSetFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeNewWalletRequested) + eventChan := make(chan *abi.BridgeRebateStakingSet) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -10018,46 +12908,47 @@ func (nwrs *BNewWalletRequestedSubscription) OnEvent( return case event := <-eventChan: handler( + event.RebateStaking, event.Raw.BlockNumber, ) } } }() - sub := nwrs.Pipe(eventChan) + sub := rsss.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (nwrs *BNewWalletRequestedSubscription) Pipe( - sink chan *abi.BridgeNewWalletRequested, +func (rsss *BRebateStakingSetSubscription) Pipe( + sink chan *abi.BridgeRebateStakingSet, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(nwrs.opts.Tick) + ticker := time.NewTicker(rsss.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := nwrs.contract.blockCounter.CurrentBlock() + lastBlock, err := rsss.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - nwrs.opts.PastBlocks + fromBlock := lastBlock - rsss.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past NewWalletRequested events "+ + "subscription monitoring fetching past RebateStakingSet events "+ "starting from block [%v]", fromBlock, ) - events, err := nwrs.contract.PastNewWalletRequestedEvents( + events, err := rsss.contract.PastRebateStakingSetEvents( fromBlock, nil, ) @@ -10069,7 +12960,7 @@ func (nwrs *BNewWalletRequestedSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past NewWalletRequested events", + "subscription monitoring fetched [%v] past RebateStakingSet events", len(events), ) @@ -10080,7 +12971,7 @@ func (nwrs *BNewWalletRequestedSubscription) Pipe( } }() - sub := nwrs.contract.watchNewWalletRequested( + sub := rsss.contract.watchRebateStakingSet( sink, ) @@ -10090,11 +12981,11 @@ func (nwrs *BNewWalletRequestedSubscription) Pipe( }) } -func (b *Bridge) watchNewWalletRequested( - sink chan *abi.BridgeNewWalletRequested, +func (b *Bridge) watchRebateStakingSet( + sink chan *abi.BridgeRebateStakingSet, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchNewWalletRequested( + return b.contract.WatchRebateStakingSet( &bind.WatchOpts{Context: ctx}, sink, ) @@ -10102,7 +12993,7 @@ func (b *Bridge) watchNewWalletRequested( thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event NewWalletRequested had to be "+ + "subscription to event RebateStakingSet had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -10111,7 +13002,7 @@ func (b *Bridge) watchNewWalletRequested( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event NewWalletRequested failed "+ + "subscription to event RebateStakingSet failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -10127,11 +13018,11 @@ func (b *Bridge) watchNewWalletRequested( ) } -func (b *Bridge) PastNewWalletRequestedEvents( +func (b *Bridge) PastRebateStakingSetEvents( startBlock uint64, endBlock *uint64, -) ([]*abi.BridgeNewWalletRequested, error) { - iterator, err := b.contract.FilterNewWalletRequested( +) ([]*abi.BridgeRebateStakingSet, error) { + iterator, err := b.contract.FilterRebateStakingSet( &bind.FilterOpts{ Start: startBlock, End: endBlock, @@ -10139,12 +13030,12 @@ func (b *Bridge) PastNewWalletRequestedEvents( ) if err != nil { return nil, fmt.Errorf( - "error retrieving past NewWalletRequested events: [%v]", + "error retrieving past RebateStakingSet events: [%v]", err, ) } - events := make([]*abi.BridgeNewWalletRequested, 0) + events := make([]*abi.BridgeRebateStakingSet, 0) for iterator.Next() { event := iterator.Event @@ -11301,6 +14192,223 @@ func (b *Bridge) PastSpvMaintainerStatusUpdatedEvents( return events, nil } +func (b *Bridge) TaprootDepositRevealedEvent( + opts *ethereum.SubscribeOpts, + depositorFilter []common.Address, + walletPubKeyHashFilter [][20]byte, +) *BTaprootDepositRevealedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BTaprootDepositRevealedSubscription{ + b, + opts, + depositorFilter, + walletPubKeyHashFilter, + } +} + +type BTaprootDepositRevealedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + depositorFilter []common.Address + walletPubKeyHashFilter [][20]byte +} + +type bridgeTaprootDepositRevealedFunc func( + FundingTxHash [32]byte, + FundingOutputIndex uint32, + Depositor common.Address, + Amount uint64, + BlindingFactor [8]byte, + WalletPubKeyHash [20]byte, + WalletXOnlyPublicKey [32]byte, + RefundPubKeyHash [20]byte, + RefundXOnlyPublicKey [32]byte, + RefundLocktime [4]byte, + Vault common.Address, + blockNumber uint64, +) + +func (tdrs *BTaprootDepositRevealedSubscription) OnEvent( + handler bridgeTaprootDepositRevealedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeTaprootDepositRevealed) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.FundingTxHash, + event.FundingOutputIndex, + event.Depositor, + event.Amount, + event.BlindingFactor, + event.WalletPubKeyHash, + event.WalletXOnlyPublicKey, + event.RefundPubKeyHash, + event.RefundXOnlyPublicKey, + event.RefundLocktime, + event.Vault, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := tdrs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (tdrs *BTaprootDepositRevealedSubscription) Pipe( + sink chan *abi.BridgeTaprootDepositRevealed, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(tdrs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := tdrs.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - tdrs.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past TaprootDepositRevealed events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := tdrs.contract.PastTaprootDepositRevealedEvents( + fromBlock, + nil, + tdrs.depositorFilter, + tdrs.walletPubKeyHashFilter, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past TaprootDepositRevealed events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := tdrs.contract.watchTaprootDepositRevealed( + sink, + tdrs.depositorFilter, + tdrs.walletPubKeyHashFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchTaprootDepositRevealed( + sink chan *abi.BridgeTaprootDepositRevealed, + depositorFilter []common.Address, + walletPubKeyHashFilter [][20]byte, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchTaprootDepositRevealed( + &bind.WatchOpts{Context: ctx}, + sink, + depositorFilter, + walletPubKeyHashFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event TaprootDepositRevealed had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event TaprootDepositRevealed failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastTaprootDepositRevealedEvents( + startBlock uint64, + endBlock *uint64, + depositorFilter []common.Address, + walletPubKeyHashFilter [][20]byte, +) ([]*abi.BridgeTaprootDepositRevealed, error) { + iterator, err := b.contract.FilterTaprootDepositRevealed( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + depositorFilter, + walletPubKeyHashFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past TaprootDepositRevealed events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeTaprootDepositRevealed, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + func (b *Bridge) TreasuryUpdatedEvent( opts *ethereum.SubscribeOpts, ) *BTreasuryUpdatedSubscription { diff --git a/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go b/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go index b5d3591e01..2780682aac 100644 --- a/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go +++ b/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go @@ -585,4 +585,52 @@ func (wpv *WalletProposalValidator) ValidateRedemptionProposalAtBlock( return result, err } +func (wpv *WalletProposalValidator) ValidateTaprootDepositSweepProposal( + arg_proposal abi.WalletProposalValidatorDepositSweepProposal, + arg_depositsExtraInfo []abi.WalletProposalValidatorTaprootDepositExtraInfo, +) (bool, error) { + result, err := wpv.contract.ValidateTaprootDepositSweepProposal( + wpv.callerOptions, + arg_proposal, + arg_depositsExtraInfo, + ) + + if err != nil { + return result, wpv.errorResolver.ResolveError( + err, + wpv.callerOptions.From, + nil, + "validateTaprootDepositSweepProposal", + arg_proposal, + arg_depositsExtraInfo, + ) + } + + return result, err +} + +func (wpv *WalletProposalValidator) ValidateTaprootDepositSweepProposalAtBlock( + arg_proposal abi.WalletProposalValidatorDepositSweepProposal, + arg_depositsExtraInfo []abi.WalletProposalValidatorTaprootDepositExtraInfo, + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + wpv.callerOptions.From, + blockNumber, + nil, + wpv.contractABI, + wpv.caller, + wpv.errorResolver, + wpv.contractAddress, + "validateTaprootDepositSweepProposal", + &result, + arg_proposal, + arg_depositsExtraInfo, + ) + + return result, err +} + // ------ Events ------- diff --git a/pkg/chain/ethereum/tbtc_sortition_chain_views.go b/pkg/chain/ethereum/tbtc_sortition_chain_views.go new file mode 100644 index 0000000000..a6740f6bc9 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_sortition_chain_views.go @@ -0,0 +1,245 @@ +package ethereum + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/keep-network/keep-core/pkg/chain" + ecdsacontract "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen/contract" + "github.com/keep-network/keep-core/pkg/sortition" +) + +// The seven sortition.Chain methods that TbtcChain routes through +// hasFrostAuthorization() are inherently a process-global switch: a single +// MonitorPool loop reads them and therefore maintains exactly one pool. During +// the ECDSA->FROST migration an operator participates in BOTH the legacy ECDSA +// and the FROST sortition pools at once (existing ECDSA wallets keep draining +// while FROST is live), and each pool needs its own maintenance loop. These two +// views bind the sortition.Chain surface to one specific pool/registry with NO +// hasFrostAuthorization() switch, so the caller can run one MonitorPool per pool. +// +// They are used ONLY by the pool-monitoring loops; TbtcChain's own +// sortition.Chain methods (consumed by the heartbeat and other callers) are left +// unchanged, so this introduces no behavior change outside the monitor loops. +// GetOperatorID stays bound to whichever pool the view targets, matching the +// per-pool member IDs (the legacy view's GetOperatorID equals TbtcChain's +// deliberately-ECDSA-bound GetOperatorID). + +// sortitionPoolView implements the pool-bound subset of sortition.Chain shared by +// both views. The FROST and ECDSA sortition pools are the same contract type +// (ecdsacontract.EcdsaSortitionPool), distinct instances, so these seven methods +// are identical apart from which pool instance they target. +type sortitionPoolView struct { + pool *ecdsacontract.EcdsaSortitionPool + operatorAddress common.Address +} + +func (v *sortitionPoolView) IsPoolLocked() (bool, error) { + return v.pool.IsLocked() +} + +func (v *sortitionPoolView) IsEligibleForRewards() (bool, error) { + return v.pool.IsEligibleForRewards(v.operatorAddress) +} + +func (v *sortitionPoolView) CanRestoreRewardEligibility() (bool, error) { + return v.pool.CanRestoreRewardEligibility(v.operatorAddress) +} + +func (v *sortitionPoolView) RestoreRewardEligibility() error { + _, err := v.pool.RestoreRewardEligibility(v.operatorAddress) + return err +} + +func (v *sortitionPoolView) IsChaosnetActive() (bool, error) { + return v.pool.IsChaosnetActive() +} + +func (v *sortitionPoolView) IsBetaOperator() (bool, error) { + return v.pool.IsBetaOperator(v.operatorAddress) +} + +func (v *sortitionPoolView) GetOperatorID( + operatorAddress chain.Address, +) (chain.OperatorID, error) { + return getOperatorID(v.pool, common.HexToAddress(operatorAddress.String())) +} + +// ecdsaSortitionChain is a sortition.Chain bound explicitly to the legacy ECDSA +// WalletRegistry and sortition pool. Its registry methods mirror the non-FROST +// branch of the corresponding TbtcChain methods. +type ecdsaSortitionChain struct { + sortitionPoolView + walletRegistry *ecdsacontract.WalletRegistry +} + +func (c *ecdsaSortitionChain) OperatorToStakingProvider() (chain.Address, bool, error) { + stakingProvider, err := c.walletRegistry.OperatorToStakingProvider(c.operatorAddress) + if err != nil { + return "", false, fmt.Errorf( + "failed to map operator [%v] to a staking provider: [%v]", + c.operatorAddress, + err, + ) + } + + if (stakingProvider == common.Address{}) { + return "", false, nil + } + + return chain.Address(stakingProvider.Hex()), true, nil +} + +func (c *ecdsaSortitionChain) EligibleStake( + stakingProvider chain.Address, +) (*big.Int, error) { + eligibleStake, err := c.walletRegistry.EligibleStake( + common.HexToAddress(stakingProvider.String()), + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get eligible stake for staking provider %s: [%w]", + stakingProvider, + err, + ) + } + + return eligibleStake, nil +} + +func (c *ecdsaSortitionChain) IsOperatorInPool() (bool, error) { + return c.walletRegistry.IsOperatorInPool(c.operatorAddress) +} + +func (c *ecdsaSortitionChain) IsOperatorUpToDate() (bool, error) { + return c.walletRegistry.IsOperatorUpToDate(c.operatorAddress) +} + +func (c *ecdsaSortitionChain) JoinSortitionPool() error { + _, err := c.walletRegistry.JoinSortitionPool() + return err +} + +func (c *ecdsaSortitionChain) UpdateOperatorStatus() error { + _, err := c.walletRegistry.UpdateOperatorStatus(c.operatorAddress) + return err +} + +// frostSortitionChain is a sortition.Chain bound explicitly to the FROST +// WalletRegistry and sortition pool. Its registry methods mirror the FROST +// branch of the corresponding TbtcChain methods, including the explicit CallOpts +// and the transaction-mutex/nonce-serialized submission helper (shared with the +// ECDSA registry binding, so the two monitor loops never race on the operator +// account nonce). +type frostSortitionChain struct { + sortitionPoolView + tc *TbtcChain +} + +func (c *frostSortitionChain) OperatorToStakingProvider() (chain.Address, bool, error) { + stakingProvider, err := c.tc.frostWalletRegistry.OperatorToStakingProvider( + &bind.CallOpts{From: c.operatorAddress}, + c.operatorAddress, + ) + if err != nil { + return "", false, fmt.Errorf( + "failed to map operator [%v] to a staking provider: [%v]", + c.operatorAddress, + err, + ) + } + + if (stakingProvider == common.Address{}) { + return "", false, nil + } + + return chain.Address(stakingProvider.Hex()), true, nil +} + +func (c *frostSortitionChain) EligibleStake( + stakingProvider chain.Address, +) (*big.Int, error) { + eligibleStake, err := c.tc.frostWalletRegistry.EligibleStake( + &bind.CallOpts{From: c.operatorAddress}, + common.HexToAddress(stakingProvider.String()), + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get eligible stake for staking provider %s: [%w]", + stakingProvider, + err, + ) + } + + return eligibleStake, nil +} + +func (c *frostSortitionChain) IsOperatorInPool() (bool, error) { + return c.tc.frostWalletRegistry.IsOperatorInPool( + &bind.CallOpts{From: c.operatorAddress}, + c.operatorAddress, + ) +} + +func (c *frostSortitionChain) IsOperatorUpToDate() (bool, error) { + return c.tc.frostWalletRegistry.IsOperatorUpToDate( + &bind.CallOpts{From: c.operatorAddress}, + c.operatorAddress, + ) +} + +func (c *frostSortitionChain) JoinSortitionPool() error { + return c.tc.submitFrostWalletRegistryTransaction( + "joinSortitionPool", + func(opts *bind.TransactOpts) (*types.Transaction, error) { + return c.tc.frostWalletRegistry.JoinSortitionPool(opts) + }, + ) +} + +func (c *frostSortitionChain) UpdateOperatorStatus() error { + return c.tc.submitFrostWalletRegistryTransaction( + "updateOperatorStatus", + func(opts *bind.TransactOpts) (*types.Transaction, error) { + return c.tc.frostWalletRegistry.UpdateOperatorStatus( + opts, + c.operatorAddress, + ) + }, + ) +} + +// LegacyECDSASortitionChain returns a sortition.Chain bound explicitly to the +// legacy ECDSA sortition pool, for ECDSA pool monitoring during the FROST +// migration drain (independent of whether FROST authorization is configured). +func (tc *TbtcChain) LegacyECDSASortitionChain() sortition.Chain { + return &ecdsaSortitionChain{ + sortitionPoolView: sortitionPoolView{ + pool: tc.sortitionPool, + operatorAddress: tc.key.Address, + }, + walletRegistry: tc.walletRegistry, + } +} + +// FrostSortitionChain returns a sortition.Chain bound explicitly to the FROST +// sortition pool, and a flag reporting whether FROST authorization is configured +// for this node. When the flag is false, the returned chain is nil and FROST +// pool monitoring must not be started. +func (tc *TbtcChain) FrostSortitionChain() (sortition.Chain, bool) { + if !tc.hasFrostAuthorization() { + return nil, false + } + + return &frostSortitionChain{ + sortitionPoolView: sortitionPoolView{ + pool: tc.frostSortitionPool, + operatorAddress: tc.key.Address, + }, + tc: tc, + }, true +} diff --git a/pkg/chain/ethereum/tbtc_sortition_chain_views_test.go b/pkg/chain/ethereum/tbtc_sortition_chain_views_test.go new file mode 100644 index 0000000000..25be8068e5 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_sortition_chain_views_test.go @@ -0,0 +1,116 @@ +package ethereum + +import ( + "testing" + + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + + ecdsacontract "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen/contract" + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" +) + +// The pool-monitoring defect these views fix is a WRONG-POOL binding: a single +// hasFrostAuthorization()-switched monitor maintained the FROST pool while +// labeled "legacy ECDSA" (and, post-cutover, nothing). These tests pin each view +// to its intended pool/registry so a regression that crosses the wiring fails. + +func newTbtcChainForSortitionViewTest(withFrost bool) ( + tc *TbtcChain, + ecdsaPool *ecdsacontract.EcdsaSortitionPool, + frostPool *ecdsacontract.EcdsaSortitionPool, + operatorAddress common.Address, +) { + operatorAddress = common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ) + ecdsaPool = &ecdsacontract.EcdsaSortitionPool{} + frostPool = &ecdsacontract.EcdsaSortitionPool{} + + tc = &TbtcChain{ + baseChain: &baseChain{ + key: &keystore.Key{Address: operatorAddress}, + }, + walletRegistry: &ecdsacontract.WalletRegistry{}, + sortitionPool: ecdsaPool, + } + if withFrost { + tc.frostWalletRegistry = &frostabi.FrostWalletRegistry{} + tc.frostSortitionPool = frostPool + } + + return tc, ecdsaPool, frostPool, operatorAddress +} + +func TestLegacyECDSASortitionChainBindsToECDSAPool(t *testing.T) { + // Even with FROST configured, the legacy view must bind to the ECDSA pool -- + // this is the exact crossing the switched monitor got wrong. + tc, ecdsaPool, frostPool, operatorAddress := newTbtcChainForSortitionViewTest(true) + + view := tc.LegacyECDSASortitionChain() + + ecdsaView, ok := view.(*ecdsaSortitionChain) + if !ok { + t.Fatalf("expected *ecdsaSortitionChain, got [%T]", view) + } + if ecdsaView.pool != ecdsaPool { + t.Fatal("legacy view must target the ECDSA sortition pool") + } + if ecdsaView.pool == frostPool { + t.Fatal("legacy view must NOT target the FROST sortition pool") + } + if ecdsaView.walletRegistry != tc.walletRegistry { + t.Fatal("legacy view must target the ECDSA wallet registry") + } + if ecdsaView.operatorAddress != operatorAddress { + t.Fatalf( + "unexpected operator address\nexpected: [%v]\nactual: [%v]", + operatorAddress, + ecdsaView.operatorAddress, + ) + } +} + +func TestFrostSortitionChainBindsToFrostPoolWhenConfigured(t *testing.T) { + tc, ecdsaPool, frostPool, operatorAddress := newTbtcChainForSortitionViewTest(true) + + view, configured := tc.FrostSortitionChain() + + if !configured { + t.Fatal("FROST view must be configured when FROST contracts are set") + } + frostView, ok := view.(*frostSortitionChain) + if !ok { + t.Fatalf("expected *frostSortitionChain, got [%T]", view) + } + if frostView.pool != frostPool { + t.Fatal("FROST view must target the FROST sortition pool") + } + if frostView.pool == ecdsaPool { + t.Fatal("FROST view must NOT target the ECDSA sortition pool") + } + if frostView.tc != tc { + t.Fatal("FROST view must reference the owning chain for tx submission") + } + if frostView.operatorAddress != operatorAddress { + t.Fatalf( + "unexpected operator address\nexpected: [%v]\nactual: [%v]", + operatorAddress, + frostView.operatorAddress, + ) + } +} + +func TestFrostSortitionChainUnconfiguredWhenFrostAbsent(t *testing.T) { + // No FROST contracts -> the FROST monitor loop must not start. + tc, _, _, _ := newTbtcChainForSortitionViewTest(false) + + view, configured := tc.FrostSortitionChain() + + if configured { + t.Fatal("FROST view must be unconfigured when FROST contracts are absent") + } + if view != nil { + t.Fatal("FROST view must be nil when FROST is not configured") + } +} diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 1c9eef1be0..b2c800d206 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -4,12 +4,19 @@ import ( "bytes" "crypto/ecdsa" "encoding/hex" + "errors" "fmt" "math/big" "reflect" + "strings" "testing" + "github.com/ethereum/go-ethereum/core/types" "github.com/keep-network/keep-core/pkg/bitcoin" + ecdsacontract "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen/contract" + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" + tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + tbtcpkg "github.com/keep-network/keep-core/pkg/tbtc" "github.com/keep-network/keep-core/pkg/chain" @@ -323,6 +330,743 @@ func TestCalculateWalletID(t *testing.T) { testutils.AssertBytesEqual(t, expectedWalletID[:], actualWalletID[:]) } +func TestTbtcChainHasFrostAuthorization(t *testing.T) { + tests := map[string]struct { + chain *TbtcChain + expectedResult bool + }{ + "no frost contracts": { + chain: &TbtcChain{}, + expectedResult: false, + }, + "registry only": { + chain: &TbtcChain{ + frostWalletRegistry: &frostabi.FrostWalletRegistry{}, + }, + expectedResult: false, + }, + "sortition pool only": { + chain: &TbtcChain{ + frostSortitionPool: &ecdsacontract.EcdsaSortitionPool{}, + }, + expectedResult: false, + }, + "registry and sortition pool": { + chain: &TbtcChain{ + frostWalletRegistry: &frostabi.FrostWalletRegistry{}, + frostSortitionPool: &ecdsacontract.EcdsaSortitionPool{}, + }, + expectedResult: true, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + actualResult := test.chain.hasFrostAuthorization() + if actualResult != test.expectedResult { + t.Fatalf( + "unexpected FROST authorization result\nexpected: [%v]\nactual: [%v]", + test.expectedResult, + actualResult, + ) + } + }) + } +} + +type operatorIDResolverMock struct { + expectedOperator common.Address + operatorID chain.OperatorID + err error + called bool +} + +func (oirm *operatorIDResolverMock) GetOperatorID( + operator common.Address, +) (chain.OperatorID, error) { + oirm.called = true + + if operator != oirm.expectedOperator { + return 0, fmt.Errorf( + "unexpected operator address\nexpected: [%v]\nactual: [%v]", + oirm.expectedOperator, + operator, + ) + } + + return oirm.operatorID, oirm.err +} + +func TestGetOperatorIDUsesProvidedResolver(t *testing.T) { + expectedOperator := common.HexToAddress( + "0x7777777777777777777777777777777777777777", + ) + expectedOperatorID := chain.OperatorID(777) + + resolver := &operatorIDResolverMock{ + expectedOperator: expectedOperator, + operatorID: expectedOperatorID, + } + + actualOperatorID, err := getOperatorID(resolver, expectedOperator) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if !resolver.called { + t.Fatal("expected operator ID resolver to be called") + } + + if actualOperatorID != expectedOperatorID { + t.Fatalf( + "unexpected operator ID\nexpected: [%v]\nactual: [%v]", + expectedOperatorID, + actualOperatorID, + ) + } +} + +type pastNewWalletRegisteredV2EventsBridgeMock struct { + pastEvents func( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) +} + +func (m *pastNewWalletRegisteredV2EventsBridgeMock) PastNewWalletRegisteredV2Events( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, +) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + return m.pastEvents( + startBlock, + endBlock, + walletID, + ecdsaWalletID, + walletPublicKeyHash, + ) +} + +type pastNewWalletRegisteredV2EventsAltFieldBridgeMock struct { + pastEvents func( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + ) ([]*pastNewWalletRegisteredV2EventsAltFieldEvent, error) +} + +type pastNewWalletRegisteredV2EventsAltFieldEvent struct { + WalletID [32]byte + EcdsaWalletID [32]byte + WalletPublicKeyHash [20]byte + Raw types.Log +} + +func (m *pastNewWalletRegisteredV2EventsAltFieldBridgeMock) PastNewWalletRegisteredV2Events( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, +) ([]*pastNewWalletRegisteredV2EventsAltFieldEvent, error) { + return m.pastEvents( + startBlock, + endBlock, + walletID, + ecdsaWalletID, + walletPublicKeyHash, + ) +} + +type pastNewWalletRegisteredV2EventsMissingRawBridgeMock struct { + pastEvents func( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + ) ([]*pastNewWalletRegisteredV2EventsMissingRawEvent, error) +} + +type pastNewWalletRegisteredV2EventsMissingRawEvent struct { + WalletID [32]byte + EcdsaWalletID [32]byte + WalletPubKeyHash [20]byte +} + +func (m *pastNewWalletRegisteredV2EventsMissingRawBridgeMock) PastNewWalletRegisteredV2Events( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, +) ([]*pastNewWalletRegisteredV2EventsMissingRawEvent, error) { + return m.pastEvents( + startBlock, + endBlock, + walletID, + ecdsaWalletID, + walletPublicKeyHash, + ) +} + +type pastNewWalletRegisteredV2EventsWrongSignatureBridgeMock struct{} + +func (m *pastNewWalletRegisteredV2EventsWrongSignatureBridgeMock) PastNewWalletRegisteredV2Events( + startBlock uint64, +) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + return nil, nil +} + +func TestPastNewWalletRegisteredEvents_UsesV2EventsWhenAvailable(t *testing.T) { + startBlock := uint64(500) + endBlock := uint64(700) + + expectedWalletIDA := [32]byte{0xaa} + expectedWalletIDB := [32]byte{0xbb} + + expectedECDSAWalletIDA := [32]byte{0xa1} + expectedECDSAWalletIDB := [32]byte{0xb1} + + expectedWalletPublicKeyHashA := [20]byte{0x11} + expectedWalletPublicKeyHashB := [20]byte{0x22} + + legacyFallbackCalled := false + + actualEvents, err := pastNewWalletRegisteredEvents( + startBlock, + &endBlock, + nil, + nil, + nil, + &pastNewWalletRegisteredV2EventsBridgeMock{ + pastEvents: func( + actualStartBlock uint64, + actualEndBlock *uint64, + _ [][32]byte, + _ [][32]byte, + _ [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + if actualStartBlock != startBlock { + t.Fatalf("unexpected start block: [%v]", actualStartBlock) + } + + if actualEndBlock == nil || *actualEndBlock != endBlock { + t.Fatalf("unexpected end block: [%v]", actualEndBlock) + } + + // Provide events out of order to verify post-conversion sort. + return []*tbtcabi.BridgeNewWalletRegisteredV2{ + { + WalletID: expectedWalletIDB, + EcdsaWalletID: expectedECDSAWalletIDB, + WalletPubKeyHash: expectedWalletPublicKeyHashB, + Raw: types.Log{BlockNumber: 650}, + }, + { + WalletID: expectedWalletIDA, + EcdsaWalletID: expectedECDSAWalletIDA, + WalletPubKeyHash: expectedWalletPublicKeyHashA, + Raw: types.Log{BlockNumber: 600}, + }, + }, nil + }, + }, + func(uint64, *uint64, [][32]byte, [][20]byte) ([]*tbtcabi.BridgeNewWalletRegistered, error) { + legacyFallbackCalled = true + return nil, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if legacyFallbackCalled { + t.Fatal("legacy fallback should not be called when v2 events are present") + } + + if len(actualEvents) != 2 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } + + // Expect ascending block order after conversion. + if actualEvents[0].BlockNumber != 600 || actualEvents[1].BlockNumber != 650 { + t.Fatalf( + "unexpected event ordering by block: [%v], [%v]", + actualEvents[0].BlockNumber, + actualEvents[1].BlockNumber, + ) + } + + if actualEvents[0].WalletID != expectedWalletIDA || + actualEvents[1].WalletID != expectedWalletIDB { + t.Fatal("unexpected wallet IDs in converted events") + } +} + +func TestPastNewWalletRegisteredEvents_FallsBackToLegacyWhenV2Empty(t *testing.T) { + expectedECDSAWalletID := [32]byte{0xdd} + expectedWalletPublicKeyHash := [20]byte{0xee} + + legacyFallbackCalled := false + + actualEvents, err := pastNewWalletRegisteredEvents( + 1, + nil, + nil, // no canonical wallet-ID filter -> fallback path enabled + nil, + nil, + &pastNewWalletRegisteredV2EventsBridgeMock{ + pastEvents: func( + uint64, + *uint64, + [][32]byte, + [][32]byte, + [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + return []*tbtcabi.BridgeNewWalletRegisteredV2{}, nil + }, + }, + func(uint64, *uint64, [][32]byte, [][20]byte) ([]*tbtcabi.BridgeNewWalletRegistered, error) { + legacyFallbackCalled = true + return []*tbtcabi.BridgeNewWalletRegistered{ + { + EcdsaWalletID: expectedECDSAWalletID, + WalletPubKeyHash: expectedWalletPublicKeyHash, + Raw: types.Log{BlockNumber: 1000}, + }, + }, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if !legacyFallbackCalled { + t.Fatal("legacy fallback should be called when v2 events are empty") + } + + if len(actualEvents) != 1 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } + + expectedWalletID := tbtcpkg.DeriveLegacyWalletID(expectedWalletPublicKeyHash) + if actualEvents[0].WalletID != expectedWalletID { + t.Fatalf( + "unexpected derived legacy wallet ID\nexpected: [%x]\nactual: [%x]", + expectedWalletID, + actualEvents[0].WalletID, + ) + } +} + +func TestPastNewWalletRegisteredEvents_DoesNotFallbackWithWalletIDFilter(t *testing.T) { + legacyFallbackCalled := false + + walletIDFilter := [][32]byte{ + {0x1}, + } + + actualEvents, err := pastNewWalletRegisteredEvents( + 1, + nil, + walletIDFilter, + nil, + nil, + &pastNewWalletRegisteredV2EventsBridgeMock{ + pastEvents: func( + uint64, + *uint64, + [][32]byte, + [][32]byte, + [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + return []*tbtcabi.BridgeNewWalletRegisteredV2{}, nil + }, + }, + func(uint64, *uint64, [][32]byte, [][20]byte) ([]*tbtcabi.BridgeNewWalletRegistered, error) { + legacyFallbackCalled = true + return nil, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if legacyFallbackCalled { + t.Fatal("legacy fallback should be skipped when walletID filter is provided") + } + + if len(actualEvents) != 0 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } +} + +func TestPastNewWalletRegisteredV2Events_ReturnsEmptyWhenMethodUnavailable(t *testing.T) { + actualEvents, err := pastNewWalletRegisteredV2Events( + 1, + nil, + nil, + nil, + nil, + struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if len(actualEvents) != 0 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } +} + +func TestPastNewWalletRegisteredV2Events_UsesWalletPublicKeyHashFallbackField(t *testing.T) { + expectedWalletID := [32]byte{0x01} + expectedECDSAWalletID := [32]byte{0x02} + expectedWalletPublicKeyHash := [20]byte{0x03} + + actualEvents, err := pastNewWalletRegisteredV2Events( + 11, + nil, + nil, + nil, + nil, + &pastNewWalletRegisteredV2EventsAltFieldBridgeMock{ + pastEvents: func( + uint64, + *uint64, + [][32]byte, + [][32]byte, + [][20]byte, + ) ([]*pastNewWalletRegisteredV2EventsAltFieldEvent, error) { + return []*pastNewWalletRegisteredV2EventsAltFieldEvent{ + { + WalletID: expectedWalletID, + EcdsaWalletID: expectedECDSAWalletID, + WalletPublicKeyHash: expectedWalletPublicKeyHash, + Raw: types.Log{BlockNumber: 121}, + }, + }, nil + }, + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if len(actualEvents) != 1 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } + + if actualEvents[0].WalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualEvents[0].WalletPublicKeyHash, + ) + } +} + +func TestPastNewWalletRegisteredV2Events_ReturnsErrorOnCallPanic(t *testing.T) { + _, err := pastNewWalletRegisteredV2Events( + 1, + nil, + nil, + nil, + nil, + &pastNewWalletRegisteredV2EventsWrongSignatureBridgeMock{}, + ) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "panic calling PastNewWalletRegisteredV2Events") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestPastNewWalletRegisteredV2Events_ReturnsErrorWhenRawMissing(t *testing.T) { + _, err := pastNewWalletRegisteredV2Events( + 1, + nil, + nil, + nil, + nil, + &pastNewWalletRegisteredV2EventsMissingRawBridgeMock{ + pastEvents: func( + uint64, + *uint64, + [][32]byte, + [][32]byte, + [][20]byte, + ) ([]*pastNewWalletRegisteredV2EventsMissingRawEvent, error) { + return []*pastNewWalletRegisteredV2EventsMissingRawEvent{ + { + WalletID: [32]byte{0x05}, + EcdsaWalletID: [32]byte{0x06}, + WalletPubKeyHash: [20]byte{0x07}, + }, + }, nil + }, + }, + ) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "raw event payload") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +type walletIDForWalletPublicKeyHashBridgeMock struct { + resolve func(walletPublicKeyHash [20]byte) ([32]byte, error) +} + +func (m *walletIDForWalletPublicKeyHashBridgeMock) WalletID( + walletPublicKeyHash [20]byte, +) ([32]byte, error) { + return m.resolve(walletPublicKeyHash) +} + +func TestResolveWalletID(t *testing.T) { + walletPublicKeyHash := [20]byte{0xaa} + legacyEcdsaWalletID := [32]byte{0x07} // non-zero -> legacy ECDSA wallet + frostEcdsaWalletID := [32]byte{} // zero -> FROST wallet + + t.Run("returns the canonical wallet ID when the accessor succeeds", func(t *testing.T) { + expectedWalletID := [32]byte{0x01} + + actualWalletID, err := resolveWalletID( + &walletIDForWalletPublicKeyHashBridgeMock{ + resolve: func(actual [20]byte) ([32]byte, error) { + if actual != walletPublicKeyHash { + t.Fatalf("unexpected wallet public key hash: [%x]", actual) + } + return expectedWalletID, nil + }, + }, + walletPublicKeyHash, + frostEcdsaWalletID, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if actualWalletID != expectedWalletID { + t.Fatalf( + "unexpected wallet ID\nexpected: [%x]\nactual: [%x]", + expectedWalletID, + actualWalletID, + ) + } + }) + + t.Run("surfaces the error for a FROST wallet when the accessor fails", func(t *testing.T) { + // A FROST wallet (zero ECDSA wallet ID) requires its canonical ID; the + // legacy derivation would be the wrong value (wrong P2WPKH vs P2TR + // script), so the error must surface rather than fall back. + _, err := resolveWalletID( + &walletIDForWalletPublicKeyHashBridgeMock{ + resolve: func([20]byte) ([32]byte, error) { + return [32]byte{}, errors.New("execution reverted: temporary") + }, + }, + walletPublicKeyHash, + frostEcdsaWalletID, + ) + if err == nil { + t.Fatal("expected an error for a FROST wallet with an unresolvable canonical ID") + } + }) + + t.Run("falls back to the legacy ID for a legacy ECDSA wallet on accessor error", func(t *testing.T) { + // Regression for legacy on-chain Bridges: the accessor exists in the + // binding but the contract lacks the walletID function, so the call + // returns a normal RPC/ABI error (NOT a typed missing-accessor signal). A + // legacy ECDSA wallet (non-zero ECDSA wallet ID) must still fall back. + actualWalletID, err := resolveWalletID( + &walletIDForWalletPublicKeyHashBridgeMock{ + resolve: func([20]byte) ([32]byte, error) { + return [32]byte{}, errors.New("execution reverted") + }, + }, + walletPublicKeyHash, + legacyEcdsaWalletID, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if expected := tbtcpkg.DeriveLegacyWalletID(walletPublicKeyHash); actualWalletID != expected { + t.Fatalf( + "unexpected wallet ID\nexpected: [%x]\nactual: [%x]", + expected, + actualWalletID, + ) + } + }) + + t.Run("falls back to the legacy ID for a legacy wallet on a Bridge without the accessor", func(t *testing.T) { + // Legacy deployment where the binding itself lacks the accessor. + actualWalletID, err := resolveWalletID( + struct{}{}, + walletPublicKeyHash, + legacyEcdsaWalletID, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if expected := tbtcpkg.DeriveLegacyWalletID(walletPublicKeyHash); actualWalletID != expected { + t.Fatalf( + "unexpected wallet ID\nexpected: [%x]\nactual: [%x]", + expected, + actualWalletID, + ) + } + }) +} + +type walletPublicKeyHashForWalletIDBridgeMock struct { + resolve func(walletID [32]byte) ([20]byte, error) +} + +func (m *walletPublicKeyHashForWalletIDBridgeMock) WalletPubKeyHashForWalletID( + walletID [32]byte, +) ([20]byte, error) { + return m.resolve(walletID) +} + +func TestResolveWalletPublicKeyHashForWalletID(t *testing.T) { + t.Run("returns canonical mapping when non-zero", func(t *testing.T) { + walletID := [32]byte{0x01} + expectedWalletPublicKeyHash := [20]byte{0xaa} + + actualWalletPublicKeyHash, err := resolveWalletPublicKeyHashForWalletID( + walletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func(actualWalletID [32]byte) ([20]byte, error) { + if actualWalletID != walletID { + t.Fatalf("unexpected wallet ID: [%x]", actualWalletID) + } + + return expectedWalletPublicKeyHash, nil + }, + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actualWalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualWalletPublicKeyHash, + ) + } + }) + + t.Run("falls back to legacy extraction when canonical lookup errors", func(t *testing.T) { + expectedWalletPublicKeyHash := [20]byte{0xbb} + legacyWalletID := tbtcpkg.DeriveLegacyWalletID(expectedWalletPublicKeyHash) + + actualWalletPublicKeyHash, err := resolveWalletPublicKeyHashForWalletID( + legacyWalletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func([32]byte) ([20]byte, error) { + return [20]byte{}, errors.New("canonical lookup unavailable") + }, + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actualWalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualWalletPublicKeyHash, + ) + } + }) + + t.Run("falls back to legacy extraction when canonical lookup returns zero", func(t *testing.T) { + expectedWalletPublicKeyHash := [20]byte{0xbc} + legacyWalletID := tbtcpkg.DeriveLegacyWalletID(expectedWalletPublicKeyHash) + + actualWalletPublicKeyHash, err := resolveWalletPublicKeyHashForWalletID( + legacyWalletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func([32]byte) ([20]byte, error) { + return [20]byte{}, nil + }, + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actualWalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualWalletPublicKeyHash, + ) + } + }) + + t.Run("returns wrapped canonical error for non-legacy IDs", func(t *testing.T) { + walletID := [32]byte{0xff} + canonicalErr := errors.New("rpc failure") + + _, err := resolveWalletPublicKeyHashForWalletID( + walletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func([32]byte) ([20]byte, error) { + return [20]byte{}, canonicalErr + }, + }, + ) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "cannot resolve wallet public key hash") { + t.Fatalf("unexpected error: [%v]", err) + } + if !strings.Contains(err.Error(), canonicalErr.Error()) { + t.Fatalf("expected canonical error to be wrapped: [%v]", err) + } + }) + + t.Run("returns not found for non-legacy IDs when canonical lookup returns zero", func(t *testing.T) { + walletID := [32]byte{0xfe} + + _, err := resolveWalletPublicKeyHashForWalletID( + walletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func([32]byte) ([20]byte, error) { + return [20]byte{}, nil + }, + }, + ) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "wallet public key hash not found") { + t.Fatalf("unexpected error: [%v]", err) + } + }) +} + func TestParseDkgResultValidationOutcome(t *testing.T) { isValid, err := parseDkgResultValidationOutcome( &struct { diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index 219216c22d..c76e609221 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -48,6 +48,12 @@ type PerformanceMetrics struct { // Gauges track current values (like queue sizes) gaugesMutex sync.RWMutex gauges map[string]*gauge + + // signingLiveness is the process-wide signing-attempt liveness tracker + // (RFC-21 Annex B implied-f alerting), shared by all wallet signing + // executors so their attempts feed one rolling window. + signingLivenessOnce sync.Once + signingLiveness *SigningAttemptLivenessTracker } // Ensure PerformanceMetrics implements PerformanceMetricsRecorder @@ -113,6 +119,7 @@ func (pm *PerformanceMetrics) registerAllMetrics() { MetricSigningSuccessTotal, MetricSigningFailedTotal, MetricSigningTimeoutsTotal, + MetricSigningNativeTBTCSignerFallbackTotal, MetricRedemptionExecutionsTotal, MetricRedemptionExecutionsSuccessTotal, MetricRedemptionExecutionsFailedTotal, @@ -170,43 +177,59 @@ func (pm *PerformanceMetrics) registerAllMetrics() { } // Register per-action type wallet metrics - // For each action type, register: total, success_total, failed_total, duration_seconds + // For each action type, register: total, success_total, failed_total, duration_seconds. + // Collect first, then initialize all maps, and only then register observers to + // avoid concurrent map writes while observers are reading. + perActionCounters := []string{} + perActionDurations := []string{} for _, actionType := range GetAllWalletActionTypes() { - actionCounters := []string{ + perActionCounters = append( + perActionCounters, WalletActionMetricName(actionType, "total"), WalletActionMetricName(actionType, "success_total"), WalletActionMetricName(actionType, "failed_total"), - } - for _, name := range actionCounters { - pm.countersMutex.Lock() - pm.counters[name] = &counter{value: 0} - pm.countersMutex.Unlock() - metricName := name // Capture for closure - pm.registry.ObserveApplicationSource( - "performance", - map[string]Source{ - metricName: func() float64 { - pm.countersMutex.RLock() - c, exists := pm.counters[metricName] - pm.countersMutex.RUnlock() - if !exists { - return 0 - } - c.mutex.RLock() - defer c.mutex.RUnlock() - return c.value - }, + ) + perActionDurations = append( + perActionDurations, + WalletActionMetricName(actionType, "duration_seconds"), + ) + } + + pm.countersMutex.Lock() + for _, name := range perActionCounters { + pm.counters[name] = &counter{value: 0} + } + pm.countersMutex.Unlock() + + for _, name := range perActionCounters { + metricName := name // Capture for closure + pm.registry.ObserveApplicationSource( + "performance", + map[string]Source{ + metricName: func() float64 { + pm.countersMutex.RLock() + c, exists := pm.counters[metricName] + pm.countersMutex.RUnlock() + if !exists { + return 0 + } + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.value }, - ) - } + }, + ) + } - // Register duration metric for this action type - durationName := WalletActionMetricName(actionType, "duration_seconds") - pm.histogramsMutex.Lock() + pm.histogramsMutex.Lock() + for _, durationName := range perActionDurations { pm.histograms[durationName] = &histogram{ buckets: make(map[float64]float64), } - pm.histogramsMutex.Unlock() + } + pm.histogramsMutex.Unlock() + + for _, durationName := range perActionDurations { durationMetricName := durationName // Capture for closure pm.registry.ObserveApplicationSource( "performance", @@ -295,6 +318,9 @@ func (pm *PerformanceMetrics) registerAllMetrics() { MetricIncomingMessageQueueSize, MetricMessageHandlerQueueSize, MetricSigningAttemptsPerOperation, + MetricSigningAttemptRollingSuccessRate, + MetricSigningAttemptRollingSampleCount, + MetricSigningAttemptImpliedFAlert, MetricCPUUtilization, MetricMemoryUsageMB, MetricGoroutineCount, @@ -550,6 +576,18 @@ func (pm *PerformanceMetrics) updateMachineStats() { } } +// SigningAttemptLivenessTracker returns the process-wide signing-attempt +// liveness tracker bound to this metrics instance, creating it on first +// use. Signing executors of every wallet share the returned tracker so all +// attempt outcomes feed one rolling window. +func (pm *PerformanceMetrics) SigningAttemptLivenessTracker() *SigningAttemptLivenessTracker { + pm.signingLivenessOnce.Do(func() { + pm.signingLiveness = NewSigningAttemptLivenessTracker(pm) + }) + + return pm.signingLiveness +} + // NoOpPerformanceMetrics is a no-op implementation of PerformanceMetricsRecorder // that can be used when metrics are disabled. type NoOpPerformanceMetrics struct{} @@ -616,6 +654,9 @@ const ( MetricSigningDurationSeconds = "signing_duration_seconds" MetricSigningAttemptsPerOperation = "signing_attempts_per_operation" MetricSigningTimeoutsTotal = "signing_timeouts_total" + // MetricSigningNativeTBTCSignerFallbackTotal counts the number of times the + // frost_tbtc_signer path fell back to legacy tECDSA execution. + MetricSigningNativeTBTCSignerFallbackTotal = "signing_native_tbtc_signer_fallback_total" // Redemption Metrics MetricRedemptionExecutionsTotal = "redemption_executions_total" diff --git a/pkg/clientinfo/performance_test.go b/pkg/clientinfo/performance_test.go index 75190c8545..e80817da6c 100644 --- a/pkg/clientinfo/performance_test.go +++ b/pkg/clientinfo/performance_test.go @@ -327,6 +327,7 @@ func TestMetricsInitialization(t *testing.T) { MetricDKGJoinedTotal, MetricSigningOperationsTotal, MetricSigningSuccessTotal, + MetricSigningNativeTBTCSignerFallbackTotal, } for _, counterName := range counters { diff --git a/pkg/clientinfo/signing_liveness.go b/pkg/clientinfo/signing_liveness.go new file mode 100644 index 0000000000..322e4c61f8 --- /dev/null +++ b/pkg/clientinfo/signing_liveness.go @@ -0,0 +1,149 @@ +package clientinfo + +import "sync" + +// RFC-21 Annex B codifies the liveness envelope of the serial signing +// retry loop for the production wallet shape (n = 100, t = 51): an attempt +// succeeds only when the drawn t-subset contains no byzantine member, so +// the per-attempt success probability against f byzantine-but-announcing +// members is +// +// f=1: 0.490 f=2: 0.238 f=3: 0.114 f=5: 0.025 +// +// and the deployed signingAttemptsLimit = 5 rationale assumes f <= 2. The +// annex requires alerting "when observed attempt failure rates imply f >= 3 +// behaviour" as the interim measure until Phase-7 t-of-included finalize +// removes the per-attempt veto. The defaults below implement that rule. +const ( + // SigningLivenessWindowSize is the number of most recent attempt + // outcomes the rolling window holds. + SigningLivenessWindowSize = 50 + + // SigningLivenessMinimumSamples is the minimum number of recorded + // outcomes before the implied-f alert may fire. Below this the rate + // gauge is still exported but the alert stays at 0. + SigningLivenessMinimumSamples = 50 + + // SigningLivenessAlertSuccessRateThreshold is the rolling per-attempt + // success rate below which the implied-f alert fires. 0.14 sits between + // the f=2 (0.238) and f=3 (0.114) expectations. For independent + // 50-attempt windows (normal approximation of the Annex B i.i.d. + // model): a true f=2 fleet false-alarms on ~4% of windows, a true f=3 + // fleet alerts on ~64%, f>=4 on ~97%, and the staggered-silence + // adversary profile (deterministic attempt failure) alerts with + // certainty. Two caveats for tuning against the Phase 5 baseline + // calibration worksheet: benign failures (network flakes, restarts) + // depress the observed rate below the model, so a measured benign + // failure rate may justify raising the threshold; and the window + // slides per attempt, so consecutive evaluations are correlated - + // operators should alert on a sustained value, not a single scrape. + SigningLivenessAlertSuccessRateThreshold = 0.14 +) + +// Signing-attempt liveness gauges. The rate and sample-count gauges expose +// the raw rolling window so operators can apply their own thresholds; the +// alert gauge encodes the documented Annex B default (1 = observed attempt +// failure rates imply f >= 3 under the sampling model). +// +// These constants are the registry source keys, NOT the exported series +// names: ObserveApplicationSource prepends the "performance_" application +// prefix, so the series operators query are +// performance_signing_attempt_rolling_success_rate and friends. Operator +// docs must use the prefixed form. +const ( + MetricSigningAttemptRollingSuccessRate = "signing_attempt_rolling_success_rate" + MetricSigningAttemptRollingSampleCount = "signing_attempt_rolling_sample_count" + MetricSigningAttemptImpliedFAlert = "signing_attempt_implied_f_alert" +) + +// SigningLivenessGaugeSetter is the minimal recorder surface the tracker +// needs. +type SigningLivenessGaugeSetter interface { + SetGauge(name string, value float64) +} + +// SigningAttemptLivenessTracker keeps a rolling window of signing-attempt +// outcomes and exports the Annex B implied-f liveness gauges. Outcomes are +// node-level aggregates: a node serving several wallets interleaves their +// attempts in one window, which keeps gauge cardinality flat at the cost of +// diluting a single compromised wallet's signal among healthy ones - if any +// served wallet's group exhibits f >= 3 behaviour, its operations still +// drag the aggregate rate toward the alert threshold. +type SigningAttemptLivenessTracker struct { + mutex sync.Mutex + recorder SigningLivenessGaugeSetter + + window []bool + next int + filled int + successCount int + + minimumSamples int + alertThreshold float64 +} + +// NewSigningAttemptLivenessTracker creates a tracker with the documented +// Annex B defaults, exporting through the given recorder. +func NewSigningAttemptLivenessTracker( + recorder SigningLivenessGaugeSetter, +) *SigningAttemptLivenessTracker { + // The sample count can never exceed the window size, so a minimum + // above it would make the alert silently unreachable; clamp to keep + // the alert attainable if the constants ever diverge. + minimumSamples := SigningLivenessMinimumSamples + if minimumSamples > SigningLivenessWindowSize { + minimumSamples = SigningLivenessWindowSize + } + + return &SigningAttemptLivenessTracker{ + recorder: recorder, + window: make([]bool, SigningLivenessWindowSize), + minimumSamples: minimumSamples, + alertThreshold: SigningLivenessAlertSuccessRateThreshold, + } +} + +// RecordAttemptOutcome records the terminal outcome of one network-wide +// signing attempt and refreshes the exported gauges. +// +// The gauges are published while holding the tracker mutex so concurrent +// recordings (executors of different wallets share one tracker) cannot +// publish out of order and leave a stale rate or alert value standing +// until the next attempt. The nesting tracker.mutex -> recorder gauge +// lock is the only acquisition order between the two; the recorder never +// calls back into the tracker. +func (salt *SigningAttemptLivenessTracker) RecordAttemptOutcome(success bool) { + salt.mutex.Lock() + defer salt.mutex.Unlock() + + if salt.filled == len(salt.window) { + if salt.window[salt.next] { + salt.successCount-- + } + } else { + salt.filled++ + } + + salt.window[salt.next] = success + if success { + salt.successCount++ + } + salt.next = (salt.next + 1) % len(salt.window) + + samples := salt.filled + successRate := float64(salt.successCount) / float64(samples) + alert := samples >= salt.minimumSamples && + successRate < salt.alertThreshold + + salt.recorder.SetGauge(MetricSigningAttemptRollingSuccessRate, successRate) + salt.recorder.SetGauge( + MetricSigningAttemptRollingSampleCount, + float64(samples), + ) + + alertValue := 0.0 + if alert { + alertValue = 1.0 + } + salt.recorder.SetGauge(MetricSigningAttemptImpliedFAlert, alertValue) +} diff --git a/pkg/clientinfo/signing_liveness_test.go b/pkg/clientinfo/signing_liveness_test.go new file mode 100644 index 0000000000..a90b3ce3b8 --- /dev/null +++ b/pkg/clientinfo/signing_liveness_test.go @@ -0,0 +1,119 @@ +package clientinfo + +import "testing" + +type gaugeCaptureRecorder struct { + values map[string]float64 +} + +func newGaugeCaptureRecorder() *gaugeCaptureRecorder { + return &gaugeCaptureRecorder{values: make(map[string]float64)} +} + +func (gcr *gaugeCaptureRecorder) SetGauge(name string, value float64) { + gcr.values[name] = value +} + +func recordOutcomes( + tracker *SigningAttemptLivenessTracker, + successes int, + failures int, +) { + for i := 0; i < failures; i++ { + tracker.RecordAttemptOutcome(false) + } + for i := 0; i < successes; i++ { + tracker.RecordAttemptOutcome(true) + } +} + +func assertGauge( + t *testing.T, + recorder *gaugeCaptureRecorder, + name string, + expected float64, +) { + t.Helper() + + actual, exists := recorder.values[name] + if !exists { + t.Errorf("gauge [%v] was never set", name) + return + } + if actual != expected { + t.Errorf( + "unexpected value of gauge [%v]\nexpected: [%v]\nactual: [%v]", + name, + expected, + actual, + ) + } +} + +func TestSigningAttemptLivenessTracker_BelowMinimumSamples_NoAlert(t *testing.T) { + recorder := newGaugeCaptureRecorder() + tracker := NewSigningAttemptLivenessTracker(recorder) + + // One short of the minimum sample count: even a zero success rate must + // not fire the alert yet. + recordOutcomes(tracker, 0, SigningLivenessMinimumSamples-1) + + assertGauge(t, recorder, MetricSigningAttemptRollingSuccessRate, 0) + assertGauge( + t, + recorder, + MetricSigningAttemptRollingSampleCount, + float64(SigningLivenessMinimumSamples-1), + ) + assertGauge(t, recorder, MetricSigningAttemptImpliedFAlert, 0) +} + +func TestSigningAttemptLivenessTracker_AlertFiresBelowThreshold(t *testing.T) { + recorder := newGaugeCaptureRecorder() + tracker := NewSigningAttemptLivenessTracker(recorder) + + // 6 successes of 50 = 0.12, below the 0.14 threshold with a full + // window: the implied-f alert fires. + recordOutcomes(tracker, 6, 44) + + assertGauge(t, recorder, MetricSigningAttemptRollingSuccessRate, 0.12) + assertGauge(t, recorder, MetricSigningAttemptRollingSampleCount, 50) + assertGauge(t, recorder, MetricSigningAttemptImpliedFAlert, 1) +} + +func TestSigningAttemptLivenessTracker_ThresholdBoundary_NoAlert(t *testing.T) { + recorder := newGaugeCaptureRecorder() + tracker := NewSigningAttemptLivenessTracker(recorder) + + // 7 successes of 50 = 0.14 exactly: the alert requires the rate to be + // strictly below the threshold, so it must not fire. (IEEE 754 + // division is correctly rounded, so 7.0/50.0 and the 0.14 literal are + // the same float64.) + recordOutcomes(tracker, 7, 43) + + assertGauge(t, recorder, MetricSigningAttemptRollingSuccessRate, 0.14) + assertGauge(t, recorder, MetricSigningAttemptRollingSampleCount, 50) + assertGauge(t, recorder, MetricSigningAttemptImpliedFAlert, 0) +} + +func TestSigningAttemptLivenessTracker_WindowRollover(t *testing.T) { + recorder := newGaugeCaptureRecorder() + tracker := NewSigningAttemptLivenessTracker(recorder) + + // A full window of failures fires the alert... + recordOutcomes(tracker, 0, SigningLivenessWindowSize) + assertGauge(t, recorder, MetricSigningAttemptImpliedFAlert, 1) + + // ...and a subsequent full window of successes evicts every failure: + // the rate recovers to 1, the sample count stays capped at the window + // size, and the alert clears. + recordOutcomes(tracker, SigningLivenessWindowSize, 0) + assertGauge(t, recorder, MetricSigningAttemptRollingSuccessRate, 1) + assertGauge( + t, + recorder, + MetricSigningAttemptRollingSampleCount, + float64(SigningLivenessWindowSize), + ) + assertGauge(t, recorder, MetricSigningAttemptImpliedFAlert, 0) +} diff --git a/pkg/crypto/ephemeral/private_key.go b/pkg/crypto/ephemeral/private_key.go index e75376cc45..1b6345431d 100644 --- a/pkg/crypto/ephemeral/private_key.go +++ b/pkg/crypto/ephemeral/private_key.go @@ -69,6 +69,26 @@ func (pk *PrivateKey) Marshal() []byte { return (*btcec.PrivateKey)(pk).Serialize() } +// Zero best-effort scrubs the private key's secret scalar in place, so a +// short-lived key (a one-time or per-attempt ephemeral) does not linger in the +// heap - and thus a core dump or swap file - after its single use. big.Int does +// not expose zeroization, so this wipes the backing words of D directly. Callers +// that hold an ephemeral key should Zero() it as soon as they are done with it. +func (pk *PrivateKey) Zero() { + if pk == nil { + return + } + d := (*btcec.PrivateKey)(pk).D + if d == nil { + return + } + words := d.Bits() + for i := range words { + words[i] = 0 + } + d.SetInt64(0) +} + // Marshal turns a `PublicKey` into a slice of bytes. func (pk *PublicKey) Marshal() []byte { return (*btcec.PublicKey)(pk).SerializeCompressed() diff --git a/pkg/crypto/ephemeral/private_key_test.go b/pkg/crypto/ephemeral/private_key_test.go index 7ee110e7b6..ed3886627f 100644 --- a/pkg/crypto/ephemeral/private_key_test.go +++ b/pkg/crypto/ephemeral/private_key_test.go @@ -5,6 +5,35 @@ import ( "testing" ) +func TestPrivateKeyZero(t *testing.T) { + keyPair, err := GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + + allZero := func(b []byte) bool { + for _, x := range b { + if x != 0 { + return false + } + } + return true + } + + if allZero(keyPair.PrivateKey.Marshal()) { + t.Fatal("a freshly generated private key must not be all-zero") + } + + keyPair.PrivateKey.Zero() + if scrubbed := keyPair.PrivateKey.Marshal(); !allZero(scrubbed) { + t.Fatalf("Zero() must scrub the secret scalar; got [%x]", scrubbed) + } + + // Zero is nil-safe. + var nilKey *PrivateKey + nilKey.Zero() +} + func TestMarshalUnmarshalPublicKey(t *testing.T) { keyPair, err := GenerateKeyPair() if err != nil { diff --git a/pkg/frost/registry/dkg_result.go b/pkg/frost/registry/dkg_result.go new file mode 100644 index 0000000000..800e851de2 --- /dev/null +++ b/pkg/frost/registry/dkg_result.go @@ -0,0 +1,259 @@ +package registry + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/keep-network/keep-core/pkg/frost" +) + +const ( + // ResultDigestVersion is the literal version tag used by + // FrostDkgValidator.resultDigest. + ResultDigestVersion = "tbtc-frost-dkg-result-v1" +) + +var ( + uint32ArrayType = mustABIType("uint32[]") + uint8ArrayType = mustABIType("uint8[]") + uint256Type = mustABIType("uint256") + addressType = mustABIType("address") + bytes32Type = mustABIType("bytes32") + stringType = mustABIType("string") +) + +// FullMembers is the full selected group returned by the FROST sortition pool. +// +// This slice is used in the v4 digest and submitted result. Do not filter +// misbehaved members before using it for those purposes. +type FullMembers []uint32 + +// ActiveMembers is the filtered group after excluding 1-based misbehaved +// member indices. This slice is used for the submitted membersHash only. +type ActiveMembers []uint32 + +// MisbehavedMemberIndices holds sorted, unique, 1-based indices into +// FullMembers. +type MisbehavedMemberIndices []uint8 + +// Result contains the FROST DKG result fields submitted to FrostWalletRegistry. +type Result struct { + SubmitterMemberIndex uint64 + XOnlyOutputKey frost.OutputKey + MembersHash [32]byte + MisbehavedMembersIndices MisbehavedMemberIndices + Signatures []byte + SigningMembersIndices []uint64 + Members FullMembers +} + +// ActiveMembersFromMisbehaved returns the filtered active set used to compute +// Result.MembersHash. +func ActiveMembersFromMisbehaved( + members FullMembers, + misbehaved MisbehavedMemberIndices, +) (ActiveMembers, error) { + if err := validateMisbehavedMemberIndices(len(members), misbehaved); err != nil { + return nil, err + } + + if len(misbehaved) == 0 { + active := make(ActiveMembers, len(members)) + copy(active, members) + return active, nil + } + + active := make(ActiveMembers, 0, len(members)-len(misbehaved)) + misbehavedCursor := 0 + for i, member := range members { + memberIndex := uint8(i + 1) + if misbehavedCursor < len(misbehaved) && + memberIndex == misbehaved[misbehavedCursor] { + misbehavedCursor++ + continue + } + + active = append(active, member) + } + + return active, nil +} + +// AssembleResult builds a Result while keeping full and active member sets +// distinct. The full members list is persisted in the result and signed in the +// v4 digest; the filtered active members list is hashed into membersHash. +func AssembleResult( + submitterMemberIndex uint64, + xOnlyOutputKey frost.OutputKey, + members FullMembers, + misbehaved MisbehavedMemberIndices, + signatures []byte, + signingMembersIndices []uint64, +) (*Result, error) { + activeMembers, err := ActiveMembersFromMisbehaved(members, misbehaved) + if err != nil { + return nil, err + } + + membersHash, err := ActiveMembersHash(activeMembers) + if err != nil { + return nil, err + } + + result := &Result{ + SubmitterMemberIndex: submitterMemberIndex, + XOnlyOutputKey: xOnlyOutputKey, + MembersHash: membersHash, + MisbehavedMembersIndices: append(MisbehavedMemberIndices{}, misbehaved...), + Signatures: append([]byte{}, signatures...), + SigningMembersIndices: append([]uint64{}, signingMembersIndices...), + Members: append(FullMembers{}, members...), + } + + return result, nil +} + +// FullMembersHash returns keccak256(abi.encode(uint32[])). +func FullMembersHash(members FullMembers) ([32]byte, error) { + return uint32ArrayHash([]uint32(members)) +} + +// ActiveMembersHash returns keccak256(abi.encode(uint32[])). +func ActiveMembersHash(members ActiveMembers) ([32]byte, error) { + return uint32ArrayHash([]uint32(members)) +} + +// MisbehavedMembersHash returns keccak256(abi.encode(uint8[])). +func MisbehavedMembersHash( + misbehaved MisbehavedMemberIndices, +) ([32]byte, error) { + args := abi.Arguments{{Type: uint8ArrayType}} + encoded, err := args.Pack([]uint8(misbehaved)) + if err != nil { + return [32]byte{}, err + } + + return crypto.Keccak256Hash(encoded), nil +} + +// ResultDigest computes the pre-EIP-191 v4 digest expected by +// FrostDkgValidator.resultDigest. +func ResultDigest( + chainID *big.Int, + bridge common.Address, + registry common.Address, + seed *big.Int, + xOnlyOutputKey frost.OutputKey, + members FullMembers, + misbehaved MisbehavedMemberIndices, +) ([32]byte, error) { + if chainID == nil { + return [32]byte{}, fmt.Errorf("chain ID is nil") + } + if bridge == (common.Address{}) { + return [32]byte{}, fmt.Errorf("bridge address is zero") + } + if registry == (common.Address{}) { + return [32]byte{}, fmt.Errorf("registry address is zero") + } + if seed == nil { + return [32]byte{}, fmt.Errorf("seed is nil") + } + + fullMembersHash, err := FullMembersHash(members) + if err != nil { + return [32]byte{}, err + } + + misbehavedHash, err := MisbehavedMembersHash(misbehaved) + if err != nil { + return [32]byte{}, err + } + + args := abi.Arguments{ + {Type: stringType}, + {Type: uint256Type}, + {Type: addressType}, + {Type: addressType}, + {Type: uint256Type}, + {Type: bytes32Type}, + {Type: bytes32Type}, + {Type: bytes32Type}, + } + + encoded, err := args.Pack( + ResultDigestVersion, + chainID, + bridge, + registry, + seed, + [32]byte(xOnlyOutputKey), + fullMembersHash, + misbehavedHash, + ) + if err != nil { + return [32]byte{}, err + } + + return crypto.Keccak256Hash(encoded), nil +} + +// EthereumSignedMessageHash returns the go-ethereum personal-sign hash: +// keccak256("\x19Ethereum Signed Message:\n32" || digest). +func EthereumSignedMessageHash(digest [32]byte) [32]byte { + prefixed := make([]byte, 0, 28+len(digest)) + prefixed = append(prefixed, []byte("\x19Ethereum Signed Message:\n32")...) + prefixed = append(prefixed, digest[:]...) + + return crypto.Keccak256Hash(prefixed) +} + +func uint32ArrayHash(members []uint32) ([32]byte, error) { + args := abi.Arguments{{Type: uint32ArrayType}} + encoded, err := args.Pack(members) + if err != nil { + return [32]byte{}, err + } + + return crypto.Keccak256Hash(encoded), nil +} + +func validateMisbehavedMemberIndices( + groupSize int, + misbehaved MisbehavedMemberIndices, +) error { + if groupSize > 255 { + return fmt.Errorf("group size [%d] exceeds uint8 member index capacity", groupSize) + } + + var previous uint8 + for i, memberIndex := range misbehaved { + if memberIndex == 0 || int(memberIndex) > groupSize { + return fmt.Errorf( + "misbehaved member index [%d] out of range [1, %d]", + memberIndex, + groupSize, + ) + } + + if i > 0 && memberIndex <= previous { + return fmt.Errorf("misbehaved member indices must be sorted and unique") + } + + previous = memberIndex + } + + return nil +} + +func mustABIType(name string) abi.Type { + t, err := abi.NewType(name, "", nil) + if err != nil { + panic(err) + } + + return t +} diff --git a/pkg/frost/registry/dkg_result_test.go b/pkg/frost/registry/dkg_result_test.go new file mode 100644 index 0000000000..95ade636e5 --- /dev/null +++ b/pkg/frost/registry/dkg_result_test.go @@ -0,0 +1,350 @@ +package registry + +import ( + "encoding/hex" + "encoding/json" + "math/big" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/keep-network/keep-core/pkg/frost" +) + +const ( + v4DigestFixtureTestPath = "testdata/v4_digest_fixture.json" + v4DigestFixtureRepoPath = "pkg/frost/registry/testdata/v4_digest_fixture.json" +) + +type v4DigestFixture struct { + Name string `json:"name"` + Version string `json:"version"` + ChainID string `json:"chainID"` + Bridge string `json:"bridge"` + Registry string `json:"registry"` + Seed string `json:"seed"` + XOnlyOutputKey string `json:"xOnlyOutputKey"` + Members []uint32 `json:"members"` + MisbehavedMembersIndices []uint8 `json:"misbehavedMembersIndices"` + FullMembersHash string `json:"fullMembersHash"` + ActiveMembersHash string `json:"activeMembersHash"` + Digest string `json:"digest"` + EthereumSignedMessageHash string `json:"ethereumSignedMessageHash"` + Generator struct { + Source string `json:"source"` + Command string `json:"command"` + } `json:"generator"` + DriftCheck struct { + TbtcPath string `json:"tbtc_path"` + KeepCorePath string `json:"keep_core_path"` + Rule string `json:"rule"` + } `json:"drift_check"` +} + +func TestResultDigestMatchesCrossRepoFixture(t *testing.T) { + fixture := loadV4DigestFixture(t) + + chainID := mustBigInt(t, fixture.ChainID) + seed := mustBigInt(t, fixture.Seed) + digest, err := ResultDigest( + chainID, + common.HexToAddress(fixture.Bridge), + common.HexToAddress(fixture.Registry), + seed, + mustOutputKey(t, fixture.XOnlyOutputKey), + FullMembers(fixture.Members), + MisbehavedMemberIndices(fixture.MisbehavedMembersIndices), + ) + if err != nil { + t.Fatalf("unexpected digest error: [%v]", err) + } + + // Fixture generated with the TS reference shape: + // keccak256(defaultAbiCoder.encode( + // ["string","uint256","address","address","uint256","bytes32","bytes32","bytes32"], + // ["tbtc-frost-dkg-result-v1", chainID, bridge, registry, seed, + // xOnlyOutputKey, keccak256(abi.encode(uint32[] members)), + // keccak256(abi.encode(uint8[] misbehavedMembersIndices))])) + expectedDigest := mustBytes32(t, fixture.Digest) + + if digest != expectedDigest { + t.Fatalf( + "unexpected digest\nexpected: [0x%x]\nactual: [0x%x]", + expectedDigest, + digest, + ) + } +} + +func TestResultDigestRejectsZeroBindingAddresses(t *testing.T) { + fixture := loadV4DigestFixture(t) + chainID := mustBigInt(t, fixture.ChainID) + seed := mustBigInt(t, fixture.Seed) + outputKey := mustOutputKey(t, fixture.XOnlyOutputKey) + + _, err := ResultDigest( + chainID, + common.Address{}, + common.HexToAddress(fixture.Registry), + seed, + outputKey, + FullMembers(fixture.Members), + MisbehavedMemberIndices(fixture.MisbehavedMembersIndices), + ) + if err == nil || !strings.Contains(err.Error(), "bridge address is zero") { + t.Fatalf("expected zero bridge rejection, got [%v]", err) + } + + _, err = ResultDigest( + chainID, + common.HexToAddress(fixture.Bridge), + common.Address{}, + seed, + outputKey, + FullMembers(fixture.Members), + MisbehavedMemberIndices(fixture.MisbehavedMembersIndices), + ) + if err == nil || !strings.Contains(err.Error(), "registry address is zero") { + t.Fatalf("expected zero registry rejection, got [%v]", err) + } +} + +func TestMembersHashesKeepFullAndActiveSetsDistinct(t *testing.T) { + fixture := loadV4DigestFixture(t) + fullMembers := FullMembers(fixture.Members) + misbehaved := MisbehavedMemberIndices(fixture.MisbehavedMembersIndices) + + activeMembers, err := ActiveMembersFromMisbehaved(fullMembers, misbehaved) + if err != nil { + t.Fatalf("unexpected active members error: [%v]", err) + } + + expectedActiveMembers := ActiveMembers{101, 303, 404} + if len(activeMembers) != len(expectedActiveMembers) { + t.Fatalf( + "unexpected active members length\nexpected: [%d]\nactual: [%d]", + len(expectedActiveMembers), + len(activeMembers), + ) + } + for i := range expectedActiveMembers { + if activeMembers[i] != expectedActiveMembers[i] { + t.Fatalf( + "unexpected active member at index [%d]\nexpected: [%d]\nactual: [%d]", + i, + expectedActiveMembers[i], + activeMembers[i], + ) + } + } + + fullHash, err := FullMembersHash(fullMembers) + if err != nil { + t.Fatalf("unexpected full members hash error: [%v]", err) + } + + activeHash, err := ActiveMembersHash(activeMembers) + if err != nil { + t.Fatalf("unexpected active members hash error: [%v]", err) + } + + expectedFullHash := mustBytes32(t, fixture.FullMembersHash) + expectedActiveHash := mustBytes32(t, fixture.ActiveMembersHash) + + if fullHash != expectedFullHash { + t.Fatalf( + "unexpected full members hash\nexpected: [0x%x]\nactual: [0x%x]", + expectedFullHash, + fullHash, + ) + } + if activeHash != expectedActiveHash { + t.Fatalf( + "unexpected active members hash\nexpected: [0x%x]\nactual: [0x%x]", + expectedActiveHash, + activeHash, + ) + } + if fullHash == activeHash { + t.Fatal("expected full and active members hashes to differ") + } +} + +func TestAssembleResultUsesFilteredMembersHash(t *testing.T) { + fixture := loadV4DigestFixture(t) + + result, err := AssembleResult( + 1, + mustOutputKey(t, fixture.XOnlyOutputKey), + FullMembers(fixture.Members), + MisbehavedMemberIndices(fixture.MisbehavedMembersIndices), + []byte{0x01, 0x02}, + []uint64{1, 3, 4}, + ) + if err != nil { + t.Fatalf("unexpected assembly error: [%v]", err) + } + + expectedMembersHash := mustBytes32(t, fixture.ActiveMembersHash) + + if result.MembersHash != expectedMembersHash { + t.Fatalf( + "unexpected result membersHash\nexpected: [0x%x]\nactual: [0x%x]", + expectedMembersHash, + result.MembersHash, + ) + } +} + +func TestEthereumSignedMessageHash(t *testing.T) { + fixture := loadV4DigestFixture(t) + + hash := EthereumSignedMessageHash(mustBytes32(t, fixture.Digest)) + expected := mustBytes32(t, fixture.EthereumSignedMessageHash) + + if hash != expected { + t.Fatalf( + "unexpected EIP-191 hash\nexpected: [0x%x]\nactual: [0x%x]", + expected, + hash, + ) + } +} + +func TestV4DigestFixtureMetadata(t *testing.T) { + fixture := loadV4DigestFixture(t) + + if fixture.Name != "frost-dkg-result-digest-vectors" { + t.Errorf("unexpected fixture name: [%s]", fixture.Name) + } + if fixture.Version != "v1" { + t.Errorf("unexpected fixture version: [%s]", fixture.Version) + } + if fixture.Generator.Source != "tlabs-xyz/tbtc/contracts/tbtc-v2/test/integration/utils/frost-wallet-registry.ts:computeFrostResultDigest" { + t.Errorf("unexpected generator source: [%s]", fixture.Generator.Source) + } + if fixture.Generator.Command == "" { + t.Error("generator command must be documented") + } + if fixture.DriftCheck.TbtcPath != "docs/test-vectors/frost-dkg-result-digest-v1.json" { + t.Errorf("unexpected tbtc drift-check path: [%s]", fixture.DriftCheck.TbtcPath) + } + if fixture.DriftCheck.KeepCorePath != v4DigestFixtureRepoPath { + t.Errorf( + "unexpected keep-core drift-check path\nexpected: [%s]\nactual: [%s]", + v4DigestFixtureRepoPath, + fixture.DriftCheck.KeepCorePath, + ) + } + if fixture.DriftCheck.Rule == "" { + t.Error("drift-check rule must be documented") + } +} + +func TestV4DigestFixtureFileShouldExistAtMirrorPath(t *testing.T) { + fixture := loadV4DigestFixture(t) + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller: cannot locate test source file") + } + + repoRoot := filepath.Clean( + filepath.Join(filepath.Dir(thisFile), "..", "..", ".."), + ) + abs := filepath.Join(repoRoot, fixture.DriftCheck.KeepCorePath) + if _, err := os.Stat(abs); err != nil { + t.Fatalf( + "fixture self-declares it lives at [%s] resolved to [%s] but the file is not there: [%v]", + fixture.DriftCheck.KeepCorePath, + abs, + err, + ) + } +} + +func TestActiveMembersFromMisbehavedRejectsInvalidIndices(t *testing.T) { + testCases := map[string]MisbehavedMemberIndices{ + "zero": {0}, + "too large": {4}, + "duplicate": {2, 2}, + "unsorted": {3, 1}, + } + + for name, misbehaved := range testCases { + t.Run(name, func(t *testing.T) { + _, err := ActiveMembersFromMisbehaved( + FullMembers{101, 202, 303}, + misbehaved, + ) + if err == nil { + t.Fatal("expected error") + } + }) + } +} + +func loadV4DigestFixture(t *testing.T) *v4DigestFixture { + t.Helper() + + data, err := os.ReadFile(v4DigestFixtureTestPath) + if err != nil { + t.Fatalf("cannot read fixture: [%v]", err) + } + + var fixture v4DigestFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("cannot unmarshal fixture: [%v]", err) + } + + return &fixture +} + +func mustBigInt(t *testing.T, value string) *big.Int { + t.Helper() + + result, ok := new(big.Int).SetString(value, 10) + if !ok { + t.Fatalf("cannot parse big int: [%s]", value) + } + + return result +} + +func mustOutputKey(t *testing.T, hexString string) frost.OutputKey { + t.Helper() + + var outputKey frost.OutputKey + copy(outputKey[:], mustBytes(t, hexString, frost.OutputKeySize)) + return outputKey +} + +func mustBytes32(t *testing.T, hexString string) [32]byte { + t.Helper() + + var result [32]byte + copy(result[:], mustBytes(t, hexString, 32)) + return result +} + +func mustBytes(t *testing.T, hexString string, expectedLength int) []byte { + t.Helper() + + decoded, err := hex.DecodeString(strings.TrimPrefix(hexString, "0x")) + if err != nil { + t.Fatalf("cannot decode hex string: [%v]", err) + } + + if len(decoded) != expectedLength { + t.Fatalf( + "unexpected decoded length\nexpected: [%d]\nactual: [%d]", + expectedLength, + len(decoded), + ) + } + + return decoded +} diff --git a/pkg/frost/registry/testdata/README.md b/pkg/frost/registry/testdata/README.md new file mode 100644 index 0000000000..b8a5c0f8a1 --- /dev/null +++ b/pkg/frost/registry/testdata/README.md @@ -0,0 +1,16 @@ +# FROST DKG Digest Fixture + +`v4_digest_fixture.json` pins the keep-core Go digest implementation against +the tBTC TypeScript reference in +`contracts/tbtc-v2/test/integration/utils/frost-wallet-registry.ts`. + +Regenerate the hash fields from the sibling `tlabs-xyz/tbtc` checkout: + +```sh +cd ../tbtc/contracts/tbtc-v2 +pnpm exec ts-node -e 'import hre from "hardhat"; import { computeFrostResultDigest } from "./test/integration/utils/frost-wallet-registry"; const { ethers } = hre; const members = [101,202,303,404,505]; const misbehavedMembersIndices = [2,5]; const activeMembers = members.filter((_, i) => !misbehavedMembersIndices.includes(i + 1)); const digest = computeFrostResultDigest(hre, { chainId: 31337, bridge: "0x1111111111111111111111111111111111111111", registry: "0x2222222222222222222222222222222222222222", seed: 123456789, xOnlyOutputKey: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", members, misbehavedMembersIndices }); console.log(JSON.stringify({ fullMembersHash: ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(["uint32[]"], [members])), activeMembersHash: ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(["uint32[]"], [activeMembers])), digest, ethereumSignedMessageHash: ethers.utils.hashMessage(ethers.utils.arrayify(digest)) }, null, 2));' +``` + +The fixture metadata also declares the intended tBTC mirror path. The paired +tBTC-side emitter should produce byte-for-byte equivalent hash values for the +same inputs. diff --git a/pkg/frost/registry/testdata/v4_digest_fixture.json b/pkg/frost/registry/testdata/v4_digest_fixture.json new file mode 100644 index 0000000000..18fcfcb926 --- /dev/null +++ b/pkg/frost/registry/testdata/v4_digest_fixture.json @@ -0,0 +1,25 @@ +{ + "name": "frost-dkg-result-digest-vectors", + "version": "v1", + "description": "FROST DKG resultDigest v4 fixture generated from the tBTC TypeScript reference flow. The tBTC bridge-side reference is contracts/tbtc-v2/test/integration/utils/frost-wallet-registry.ts:computeFrostResultDigest.", + "generator": { + "source": "tlabs-xyz/tbtc/contracts/tbtc-v2/test/integration/utils/frost-wallet-registry.ts:computeFrostResultDigest", + "command": "See pkg/frost/registry/testdata/README.md for the verified pnpm exec ts-node command." + }, + "chainID": "31337", + "bridge": "0x1111111111111111111111111111111111111111", + "registry": "0x2222222222222222222222222222222222222222", + "seed": "123456789", + "xOnlyOutputKey": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [101, 202, 303, 404, 505], + "misbehavedMembersIndices": [2, 5], + "fullMembersHash": "0x048553822a9f886e64193ef9da3f71732a9708edb45cff48e68466e635f6dbf7", + "activeMembersHash": "0x4c78efa0361537bf6929c6e824b152e9b9be9140da28cbd9e1e569e483c4a16f", + "digest": "0x45c93e369c6e4b43cbeebf09c7c639526ea0b826d3e89d87c1cd137a08dfc1d9", + "ethereumSignedMessageHash": "0xd70747a38b3969e9d95700a9fc7eae13883f9ee960290d7aee8114623fb9d6c9", + "drift_check": { + "tbtc_path": "docs/test-vectors/frost-dkg-result-digest-v1.json", + "keep_core_path": "pkg/frost/registry/testdata/v4_digest_fixture.json", + "rule": "The tBTC-side test must produce the same digest, members hashes, and EIP-191 hash for these inputs using computeFrostResultDigest. If the digest format changes, update the tBTC emitter and this keep-core fixture together." + } +} diff --git a/pkg/frost/roast/attempt/attempt_context.go b/pkg/frost/roast/attempt/attempt_context.go new file mode 100644 index 0000000000..772ecdaa02 --- /dev/null +++ b/pkg/frost/roast/attempt/attempt_context.go @@ -0,0 +1,299 @@ +// Package attempt implements the AttemptContext type that binds every +// signing-protocol message to a deterministic, group-agreed context. +// +// This package is the Phase 1 deliverable from RFC-21 (ROAST Coordinator, +// Retry, and Transition Evidence). It introduces only the type, its +// deterministic seed derivation, and the canonical hash used to bind +// protocol messages to an attempt. No protocol behaviour changes in this +// phase; consumers are wired in later phases behind build tags. +package attempt + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "sort" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// MessageDigestLength is the canonical byte length of a signing-message +// digest carried in AttemptContext. The protocol always uses SHA-256 +// digests of the BIP-340 tag-bound payload, so 32 bytes is correct for +// every signing flow this package is concerned with. +const MessageDigestLength = 32 + +// AttemptSeedLength is the canonical byte length of the per-attempt +// participant-shuffle seed. The seed is derived, never chosen -- +// see DeriveAttemptSeed. +const AttemptSeedLength = 32 + +// AttemptContext binds an in-flight ROAST signing attempt to a +// deterministic context. Every honest signer must construct the same +// AttemptContext for a given (session, key group, message, attempt +// number) and must reject any protocol message whose AttemptContextHash +// does not match the locally-computed context. +// +// AttemptContext fields are public so test fixtures can construct +// contexts directly, but production callers should use NewAttemptContext +// which validates inputs and derives the seed. +type AttemptContext struct { + // SessionID identifies the signing session at the keep-core layer. + // It is opaque to the ROAST coordinator; the coordinator only + // requires it to be stable across the session's attempts. + SessionID string + // KeyGroupID identifies the FROST key group whose threshold share + // will sign. It is opaque to the coordinator; equality across honest + // signers is required. + KeyGroupID string + // MessageDigest is the 32-byte SHA-256 digest of the BIP-340 + // tag-bound signing message. + MessageDigest [MessageDigestLength]byte + // AttemptNumber is the zero-based ordinal of this attempt within + // the session. Attempt 0 is the first attempt; later attempts are + // driven by NextAttempt in the coordinator state machine + // (introduced in later RFC-21 phases). + AttemptNumber uint32 + // IncludedSet is the set of member indices that are eligible to + // participate in this attempt. Must be sorted ascending. Must not + // be empty. + IncludedSet []group.MemberIndex + // ExcludedSet is the set of member indices permanently excluded + // from this attempt by the coordinator's transition-evidence + // policy. Must be sorted ascending. May be empty. Permanent + // exclusion follows from transport-blamable (overflow) or + // validation-blamable (non-transport reject) evidence, never + // from silence alone. + ExcludedSet []group.MemberIndex + // TransientlyParked is the set of member indices skipped from + // THIS attempt only because they were silent (deadline expiry) + // at the previous attempt. Parking is strictly transient: a + // peer is unparked at the attempt after the one that skipped + // them, so a falsely-silenced honest peer (network blip, + // coordinator censorship caught at VerifyBundle) is reinstated + // without intervention. Must be sorted ascending. May be empty. + TransientlyParked []group.MemberIndex + // AttemptSeed is derived from group-agreed inputs and binds the + // attempt to inputs that no coordinator can manipulate. See + // DeriveAttemptSeed. + AttemptSeed [AttemptSeedLength]byte +} + +// DeriveAttemptSeed computes the per-attempt seed from inputs the group +// already agrees on. The seed binds the attempt's participant selection +// to fixed session inputs so a coordinator cannot shape the shuffle by +// picking a favourable seed. +// +// The derivation is: +// +// AttemptSeed = SHA256( +// DkgGroupPublicKey || SessionID || MessageDigest, +// ) +// +// Where SessionID is encoded as the raw UTF-8 bytes (the canonical +// representation used elsewhere in keep-core) and the other inputs are +// raw bytes. +func DeriveAttemptSeed( + dkgGroupPublicKey []byte, + sessionID string, + messageDigest [MessageDigestLength]byte, +) [AttemptSeedLength]byte { + h := sha256.New() + h.Write(dkgGroupPublicKey) + h.Write([]byte(sessionID)) + h.Write(messageDigest[:]) + var out [AttemptSeedLength]byte + copy(out[:], h.Sum(nil)) + return out +} + +// NewAttemptContext constructs an AttemptContext with the seed derived +// from group-agreed inputs. The IncludedSet and ExcludedSet are sorted +// ascending in the returned context regardless of input order; honest +// signers therefore produce identical contexts from identical input +// values. +// +// Returns an error if the included set is empty, if any member appears +// in both sets, or if either set contains duplicates. +// +// This is the seven-argument convenience that initialises an attempt +// with no TransientlyParked entries (the attempt-zero shape). For +// later attempts produced by the coordinator's NextAttempt policy, +// use NewAttemptContextWithParking. +func NewAttemptContext( + sessionID string, + keyGroupID string, + dkgGroupPublicKey []byte, + messageDigest [MessageDigestLength]byte, + attemptNumber uint32, + includedSet []group.MemberIndex, + excludedSet []group.MemberIndex, +) (AttemptContext, error) { + return NewAttemptContextWithParking( + sessionID, + keyGroupID, + dkgGroupPublicKey, + messageDigest, + attemptNumber, + includedSet, + excludedSet, + nil, + ) +} + +// NewAttemptContextWithParking is the full constructor used by the +// coordinator's NextAttempt policy. It accepts a transientlyParked +// set in addition to the inputs of NewAttemptContext. +// +// Validation: included set non-empty; no duplicates in any set; +// included/excluded sets disjoint; included/parked sets disjoint; +// excluded/parked sets disjoint. +func NewAttemptContextWithParking( + sessionID string, + keyGroupID string, + dkgGroupPublicKey []byte, + messageDigest [MessageDigestLength]byte, + attemptNumber uint32, + includedSet []group.MemberIndex, + excludedSet []group.MemberIndex, + transientlyParked []group.MemberIndex, +) (AttemptContext, error) { + if len(includedSet) == 0 { + return AttemptContext{}, errors.New( + "attempt context: included set must not be empty", + ) + } + included, err := canonicalMemberSet(includedSet, "included") + if err != nil { + return AttemptContext{}, err + } + excluded, err := canonicalMemberSet(excludedSet, "excluded") + if err != nil { + return AttemptContext{}, err + } + parked, err := canonicalMemberSet(transientlyParked, "transiently parked") + if err != nil { + return AttemptContext{}, err + } + if hasOverlap(included, excluded) { + return AttemptContext{}, errors.New( + "attempt context: included and excluded sets overlap", + ) + } + if hasOverlap(included, parked) { + return AttemptContext{}, errors.New( + "attempt context: included and transiently-parked sets overlap", + ) + } + if hasOverlap(excluded, parked) { + return AttemptContext{}, errors.New( + "attempt context: excluded and transiently-parked sets overlap", + ) + } + return AttemptContext{ + SessionID: sessionID, + KeyGroupID: keyGroupID, + MessageDigest: messageDigest, + AttemptNumber: attemptNumber, + IncludedSet: included, + ExcludedSet: excluded, + TransientlyParked: parked, + AttemptSeed: DeriveAttemptSeed( + dkgGroupPublicKey, + sessionID, + messageDigest, + ), + }, nil +} + +// Hash returns the canonical 32-byte hash of the attempt context. The +// hash is the SHA-256 of a length-prefixed, sorted-set canonical +// encoding so any two honest signers that construct semantically equal +// AttemptContexts produce byte-identical hashes regardless of input +// ordering. +// +// The hash is the value carried in protocol messages as +// AttemptContextHash. A receiver that computes a different hash than +// the one carried by an inbound message must reject the message: it +// belongs to a different attempt. +func (c AttemptContext) Hash() [MessageDigestLength]byte { + h := sha256.New() + writeLenPrefixed(h, []byte(c.SessionID)) + writeLenPrefixed(h, []byte(c.KeyGroupID)) + h.Write(c.MessageDigest[:]) + var attemptNumberBuf [4]byte + binary.BigEndian.PutUint32(attemptNumberBuf[:], c.AttemptNumber) + h.Write(attemptNumberBuf[:]) + writeMemberSet(h, c.IncludedSet) + writeMemberSet(h, c.ExcludedSet) + writeMemberSet(h, c.TransientlyParked) + h.Write(c.AttemptSeed[:]) + var out [MessageDigestLength]byte + copy(out[:], h.Sum(nil)) + return out +} + +func canonicalMemberSet( + members []group.MemberIndex, + label string, +) ([]group.MemberIndex, error) { + if len(members) == 0 { + return []group.MemberIndex{}, nil + } + out := make([]group.MemberIndex, len(members)) + copy(out, members) + sort.Slice(out, func(i, j int) bool { + return out[i] < out[j] + }) + for i := 1; i < len(out); i++ { + if out[i] == out[i-1] { + return nil, fmt.Errorf( + "attempt context: %s set contains duplicate member [%d]", + label, + out[i], + ) + } + } + return out, nil +} + +func hasOverlap(a, b []group.MemberIndex) bool { + i, j := 0, 0 + for i < len(a) && j < len(b) { + switch { + case a[i] < b[j]: + i++ + case a[i] > b[j]: + j++ + default: + return true + } + } + return false +} + +// byteWriter is the subset of io.Writer the canonical-encoding helpers +// need. Hash.Write (the only production implementation) is documented to +// never return an error, so the helpers discard the (int, error) result +// explicitly to make that contract reader-visible (and to satisfy gosec +// G104). +type byteWriter interface { + Write(p []byte) (n int, err error) +} + +func writeLenPrefixed(w byteWriter, data []byte) { + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(data))) + _, _ = w.Write(lenBuf[:]) + _, _ = w.Write(data) +} + +func writeMemberSet(w byteWriter, members []group.MemberIndex) { + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(members))) + _, _ = w.Write(lenBuf[:]) + for _, m := range members { + _, _ = w.Write([]byte{byte(m)}) + } +} diff --git a/pkg/frost/roast/attempt/attempt_context_test.go b/pkg/frost/roast/attempt/attempt_context_test.go new file mode 100644 index 0000000000..13c5408a8c --- /dev/null +++ b/pkg/frost/roast/attempt/attempt_context_test.go @@ -0,0 +1,435 @@ +package attempt + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestDeriveAttemptSeed_IsPureFunctionOfInputs(t *testing.T) { + dkgPub := []byte{0x02, 0x01, 0x02, 0x03, 0x04} + sessionID := "session-a" + var digest [MessageDigestLength]byte + copy(digest[:], bytes.Repeat([]byte{0x42}, MessageDigestLength)) + + a := DeriveAttemptSeed(dkgPub, sessionID, digest) + b := DeriveAttemptSeed(dkgPub, sessionID, digest) + if a != b { + t.Fatalf("derivation not deterministic: %x != %x", a, b) + } + + expected := sha256.Sum256( + append(append(append([]byte{}, dkgPub...), []byte(sessionID)...), digest[:]...), + ) + if a != expected { + t.Fatalf( + "derivation does not match SHA256(dkgPub || sessionID || messageDigest): got %x want %x", + a, expected, + ) + } +} + +func TestDeriveAttemptSeed_SensitiveToEachInput(t *testing.T) { + base := DeriveAttemptSeed( + []byte{0x01, 0x02}, + "session-a", + [MessageDigestLength]byte{0x01}, + ) + + tests := []struct { + name string + dkgPub []byte + sessionID string + digest [MessageDigestLength]byte + }{ + { + name: "different DKG public key", + dkgPub: []byte{0x01, 0x03}, + sessionID: "session-a", + digest: [MessageDigestLength]byte{0x01}, + }, + { + name: "different session ID", + dkgPub: []byte{0x01, 0x02}, + sessionID: "session-b", + digest: [MessageDigestLength]byte{0x01}, + }, + { + name: "different message digest", + dkgPub: []byte{0x01, 0x02}, + sessionID: "session-a", + digest: [MessageDigestLength]byte{0x02}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DeriveAttemptSeed(tt.dkgPub, tt.sessionID, tt.digest) + if got == base { + t.Fatalf("seed collided with base for %s", tt.name) + } + }) + } +} + +func TestNewAttemptContext_SortsAndDeduplicates(t *testing.T) { + dkgPub := []byte{0x01} + digest := [MessageDigestLength]byte{0xaa} + + included := []group.MemberIndex{5, 3, 4, 1, 2} + excluded := []group.MemberIndex{7, 6} + + ctx, err := NewAttemptContext( + "session", "key-group", dkgPub, digest, 0, included, excluded, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []group.MemberIndex{1, 2, 3, 4, 5} + if !memberSlicesEqual(ctx.IncludedSet, want) { + t.Fatalf( + "included set not sorted: got %v want %v", + ctx.IncludedSet, want, + ) + } + wantExcluded := []group.MemberIndex{6, 7} + if !memberSlicesEqual(ctx.ExcludedSet, wantExcluded) { + t.Fatalf( + "excluded set not sorted: got %v want %v", + ctx.ExcludedSet, wantExcluded, + ) + } + + if !bytes.Equal(included, []group.MemberIndex{5, 3, 4, 1, 2}) { + t.Fatalf( + "caller's included slice was mutated: %v", + included, + ) + } +} + +func TestNewAttemptContext_RejectsEmptyIncludedSet(t *testing.T) { + _, err := NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{}, 0, + nil, nil, + ) + if err == nil { + t.Fatal("expected error for empty included set") + } + if !strings.Contains(err.Error(), "included set must not be empty") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestNewAttemptContext_RejectsDuplicates(t *testing.T) { + tests := []struct { + name string + included []group.MemberIndex + excluded []group.MemberIndex + want string + }{ + { + name: "duplicate in included set", + included: []group.MemberIndex{1, 2, 2, 3}, + excluded: nil, + want: "included set contains duplicate", + }, + { + name: "duplicate in excluded set", + included: []group.MemberIndex{1, 2}, + excluded: []group.MemberIndex{4, 4}, + want: "excluded set contains duplicate", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{}, 0, + tt.included, tt.excluded, + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf( + "unexpected error message: got %q want substring %q", + err.Error(), tt.want, + ) + } + }) + } +} + +func TestNewAttemptContext_RejectsOverlap(t *testing.T) { + _, err := NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{}, 0, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{3, 4}, + ) + if err == nil { + t.Fatal("expected overlap error") + } + if !strings.Contains(err.Error(), "overlap") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAttemptContextHash_IsDeterministicAcrossInputOrdering(t *testing.T) { + dkgPub := []byte{0xab, 0xcd} + digest := [MessageDigestLength]byte{0x77} + + ctxA, err := NewAttemptContext( + "session", "kg", dkgPub, digest, 7, + []group.MemberIndex{5, 3, 4, 1, 2}, + []group.MemberIndex{7, 6}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ctxB, err := NewAttemptContext( + "session", "kg", dkgPub, digest, 7, + []group.MemberIndex{1, 2, 3, 4, 5}, + []group.MemberIndex{6, 7}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ctxA.Hash() != ctxB.Hash() { + t.Fatalf( + "semantically equal contexts produced different hashes: %x vs %x", + ctxA.Hash(), ctxB.Hash(), + ) + } +} + +func TestAttemptContextHash_SensitiveToEachField(t *testing.T) { + base, err := NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + baseHash := base.Hash() + + type mutator struct { + name string + fn func() (AttemptContext, error) + } + mutators := []mutator{ + { + name: "different session ID", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session-2", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different key group ID", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg-2", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different message digest", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x06}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different attempt number", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 4, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different included set", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3, 5}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different excluded set", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + nil, + ) + }, + }, + { + name: "different DKG public key", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x02}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + } + + for _, m := range mutators { + t.Run(m.name, func(t *testing.T) { + ctx, err := m.fn() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctx.Hash() == baseHash { + t.Fatalf( + "%s did not change hash; base=%x mutated=%x", + m.name, baseHash, ctx.Hash(), + ) + } + }) + } +} + +func TestAttemptContextHash_PrefixesAvoidStringConcatCollision(t *testing.T) { + // Without length-prefixed encoding, ("ab", "cd") and ("a", "bcd") would + // produce identical hashes. Verify they do not. + dkgPub := []byte{0x01} + digest := [MessageDigestLength]byte{} + + ctxA, err := NewAttemptContext( + "ab", "cd", dkgPub, digest, 0, + []group.MemberIndex{1}, nil, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ctxB, err := NewAttemptContext( + "a", "bcd", dkgPub, digest, 0, + []group.MemberIndex{1}, nil, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctxA.Hash() == ctxB.Hash() { + t.Fatalf( + "concatenated session+keyGroup collide: hash=%x", + ctxA.Hash(), + ) + } +} + +func TestAttemptContextHash_IsStableAcrossSafeFieldExtensions(t *testing.T) { + // Lock the wire encoding by asserting a specific hash output for a + // pinned fixture. If a future change to the canonical encoding + // changes this hash, that change is a wire-format break and must be + // caught at code review. + ctx, err := NewAttemptContext( + "session-pinned", + "key-group-pinned", + []byte{0xAA, 0xBB, 0xCC, 0xDD}, + [MessageDigestLength]byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + }, + 42, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4, 5}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Recompute the expected hash by independently re-implementing the + // canonical encoding here so the test catches accidental drift in + // either the production encoder or the expected hash literal. + want := referenceHashForFixture(ctx) + got := ctx.Hash() + if got != want { + t.Fatalf( + "pinned fixture hash drifted: got %x want %x", + got, want, + ) + } +} + +func memberSlicesEqual(a, b []group.MemberIndex) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// referenceHashForFixture implements the canonical encoding inline so +// the pinned-fixture test catches drift in either the production +// implementation or the test literal. +func referenceHashForFixture(ctx AttemptContext) [MessageDigestLength]byte { + h := sha256.New() + writeLP := func(b []byte) { + var l [4]byte + binary.BigEndian.PutUint32(l[:], uint32(len(b))) + h.Write(l[:]) + h.Write(b) + } + writeMS := func(ms []group.MemberIndex) { + var l [4]byte + binary.BigEndian.PutUint32(l[:], uint32(len(ms))) + h.Write(l[:]) + for _, m := range ms { + h.Write([]byte{byte(m)}) + } + } + + writeLP([]byte(ctx.SessionID)) + writeLP([]byte(ctx.KeyGroupID)) + h.Write(ctx.MessageDigest[:]) + var a [4]byte + binary.BigEndian.PutUint32(a[:], ctx.AttemptNumber) + h.Write(a[:]) + writeMS(ctx.IncludedSet) + writeMS(ctx.ExcludedSet) + writeMS(ctx.TransientlyParked) + h.Write(ctx.AttemptSeed[:]) + var out [MessageDigestLength]byte + copy(out[:], h.Sum(nil)) + return out +} diff --git a/pkg/frost/roast/attempt/evidence_recorder.go b/pkg/frost/roast/attempt/evidence_recorder.go new file mode 100644 index 0000000000..b67d23513c --- /dev/null +++ b/pkg/frost/roast/attempt/evidence_recorder.go @@ -0,0 +1,253 @@ +package attempt + +import ( + "sync" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// OverflowQuotaDefault is the default per-sender overflow event quota +// enforced by NewBoundedRecorder. It matches the categoryQuota.Overflow +// value documented in RFC-21 Layer A. +// +// A peer that overflows the inbound message channel more than the +// quota allows in a single attempt is recorded only up to the quota: +// further overflows are silently dropped by the recorder. This bounds +// the per-attempt evidence size to O(|IncludedSet| * quota) regardless +// of how aggressively a peer (or its network link) misbehaves. +const OverflowQuotaDefault uint = 8 + +// RejectQuotaDefault is the default per-sender reject event quota. +// Matches categoryQuota.Reject in RFC-21 Layer A. A reject event is +// recorded each time a peer's payload fails the validation gate +// (shouldAcceptNativeFROSTMessage returning false), regardless of +// the specific reason. +const RejectQuotaDefault uint = 8 + +// ConflictQuotaDefault is the default per-sender conflict event +// quota. Matches categoryQuota.Conflict in RFC-21 Layer A. A +// conflict event is recorded when a peer retransmits a message for +// a sender slot that already holds a byte-different contribution +// (first-write-wins reject). +const ConflictQuotaDefault uint = 4 + +// EvidenceRecorder collects bounded, per-attempt evidence of receive- +// path anomalies that the ROAST coordinator's exclusion policy may +// later consume. +// +// The interface tracks three categories of evidence: +// - Overflow: payload arrived but the inbound channel was full. +// - Reject: payload arrived but failed validation +// (shouldAcceptNativeFROSTMessage returning false). +// - Conflict: a peer's later retransmission disagreed with its +// earlier contribution for the same slot (equivocation +// signal). +// +// Silence -- peers in the IncludedSet that produced no snapshot at +// all -- is derived implicitly by the NextAttempt policy from +// (ctx.IncludedSet - bundleSenders) and does not need a recorder +// method. +// +// Implementations must be safe for concurrent calls from multiple +// goroutines, since the receive-callback closure in pkg/frost/signing +// is driven by network goroutines. +type EvidenceRecorder interface { + // RecordOverflow notes that the inbound message channel was full + // when a payload from the named sender arrived, causing the + // payload to be dropped at the receive callback. The recorder + // applies its own quota; callers do not need to suppress at the + // call site. + RecordOverflow(sender group.MemberIndex) + // RecordReject notes that a payload from the named sender failed + // the validation gate (typically shouldAcceptNativeFROSTMessage + // returning false). The reason string is preserved verbatim in + // the snapshot so the coordinator's exclusion policy can later + // route by reason if needed; the recorder applies its own + // per-sender quota regardless of reason. + RecordReject(sender group.MemberIndex, reason string) + // RecordConflict notes that a peer retransmitted a message for + // a sender slot that already holds a byte-different contribution + // (equivocation signal under the first-write-wins assembly + // policy). + RecordConflict(sender group.MemberIndex) + // Snapshot returns a copy of the recorded evidence so far. The + // returned value does not alias internal state; the recorder may + // continue receiving events after Snapshot is called. + Snapshot() Evidence +} + +// RejectEntry describes a single per-sender reject event recorded +// during an attempt. The reason captures *why* the validation gate +// rejected the payload; the coordinator's exclusion policy treats +// every distinct reason as equally blamable today, but the field +// is kept structured so future policy refinements can differentiate. +type RejectEntry struct { + Reason string + Count uint +} + +// Evidence is the per-attempt snapshot of receive-path anomalies +// captured by an EvidenceRecorder. It is the value the ROAST +// coordinator's NextAttempt policy consumes to derive the next +// attempt's ExcludedSet. +// +// Maps are nil-safe in callers: an absent key means the category +// did not fire for that sender, count zero. +type Evidence struct { + // Overflows maps each sender to the number of overflow events + // observed for that sender during the attempt, saturated at the + // recorder's overflow quota. + Overflows map[group.MemberIndex]uint + // Rejects maps each sender to a per-reason set of reject entries. + // The outer map's key is the sender; the inner slice carries one + // entry per distinct reason, with Count saturated at the + // recorder's reject quota. + Rejects map[group.MemberIndex][]RejectEntry + // Conflicts maps each sender to the number of first-write-wins + // conflict events observed during the attempt, saturated at the + // recorder's conflict quota. + Conflicts map[group.MemberIndex]uint +} + +// NewBoundedRecorder returns an EvidenceRecorder with default +// per-sender quotas across all three categories. The recorder is +// safe for concurrent use. +func NewBoundedRecorder() EvidenceRecorder { + return NewBoundedRecorderWithQuotas( + OverflowQuotaDefault, + RejectQuotaDefault, + ConflictQuotaDefault, + ) +} + +// NewBoundedRecorderWithQuota returns a recorder with a custom +// overflow quota; reject and conflict quotas use their defaults. +// Preserved as the Phase-2 entry point so existing callers do not +// need to update. +func NewBoundedRecorderWithQuota(overflowQuota uint) EvidenceRecorder { + return NewBoundedRecorderWithQuotas( + overflowQuota, + RejectQuotaDefault, + ConflictQuotaDefault, + ) +} + +// NewBoundedRecorderWithQuotas returns a recorder with custom +// per-category quotas. Intended for tests; production callers +// should use NewBoundedRecorder so the per-attempt evidence size +// is uniform across the network. +func NewBoundedRecorderWithQuotas( + overflowQuota, rejectQuota, conflictQuota uint, +) EvidenceRecorder { + return &boundedRecorder{ + overflowQuota: overflowQuota, + rejectQuota: rejectQuota, + conflictQuota: conflictQuota, + overflows: map[group.MemberIndex]uint{}, + rejects: map[group.MemberIndex]map[string]uint{}, + conflicts: map[group.MemberIndex]uint{}, + } +} + +// NoOpRecorder returns a recorder that discards every event and +// reports an empty Evidence on Snapshot. It is the default at +// every receive-loop call site when the ROAST-retry registry is +// not populated, so the receive loops' observable behaviour stays +// identical to pre-Phase-2 until a real recorder is wired. +func NoOpRecorder() EvidenceRecorder { + return noOpRecorder{} +} + +type boundedRecorder struct { + mu sync.Mutex + overflowQuota uint + rejectQuota uint + conflictQuota uint + overflows map[group.MemberIndex]uint + // rejects[sender][reason] = count. The two-level map keeps each + // reason bucket bounded by rejectQuota independently so a peer + // cannot saturate one reason to mask another (RFC-21 Layer A: + // "a peer cannot spam overflow events to drown out reject + // evidence or vice-versa"; the same principle applies within + // reject reasons). + rejects map[group.MemberIndex]map[string]uint + conflicts map[group.MemberIndex]uint +} + +func (r *boundedRecorder) RecordOverflow(sender group.MemberIndex) { + r.mu.Lock() + defer r.mu.Unlock() + if r.overflows[sender] < r.overflowQuota { + r.overflows[sender]++ + } +} + +func (r *boundedRecorder) RecordReject( + sender group.MemberIndex, + reason string, +) { + r.mu.Lock() + defer r.mu.Unlock() + bySender, ok := r.rejects[sender] + if !ok { + bySender = map[string]uint{} + r.rejects[sender] = bySender + } + if bySender[reason] < r.rejectQuota { + bySender[reason]++ + } +} + +func (r *boundedRecorder) RecordConflict(sender group.MemberIndex) { + r.mu.Lock() + defer r.mu.Unlock() + if r.conflicts[sender] < r.conflictQuota { + r.conflicts[sender]++ + } +} + +func (r *boundedRecorder) Snapshot() Evidence { + r.mu.Lock() + defer r.mu.Unlock() + overflows := make(map[group.MemberIndex]uint, len(r.overflows)) + for sender, count := range r.overflows { + overflows[sender] = count + } + rejects := make( + map[group.MemberIndex][]RejectEntry, + len(r.rejects), + ) + for sender, reasons := range r.rejects { + entries := make([]RejectEntry, 0, len(reasons)) + for reason, count := range reasons { + entries = append(entries, RejectEntry{ + Reason: reason, + Count: count, + }) + } + rejects[sender] = entries + } + conflicts := make(map[group.MemberIndex]uint, len(r.conflicts)) + for sender, count := range r.conflicts { + conflicts[sender] = count + } + return Evidence{ + Overflows: overflows, + Rejects: rejects, + Conflicts: conflicts, + } +} + +type noOpRecorder struct{} + +func (noOpRecorder) RecordOverflow(group.MemberIndex) {} +func (noOpRecorder) RecordReject(group.MemberIndex, string) {} +func (noOpRecorder) RecordConflict(group.MemberIndex) {} + +func (noOpRecorder) Snapshot() Evidence { + return Evidence{ + Overflows: map[group.MemberIndex]uint{}, + Rejects: map[group.MemberIndex][]RejectEntry{}, + Conflicts: map[group.MemberIndex]uint{}, + } +} diff --git a/pkg/frost/roast/attempt/evidence_recorder_categories_test.go b/pkg/frost/roast/attempt/evidence_recorder_categories_test.go new file mode 100644 index 0000000000..176d61f152 --- /dev/null +++ b/pkg/frost/roast/attempt/evidence_recorder_categories_test.go @@ -0,0 +1,114 @@ +package attempt + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestBoundedRecorder_RecordReject_AccumulatesByReason(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordReject(1, "validation_gate_rejected") + rec.RecordReject(1, "validation_gate_rejected") + rec.RecordReject(1, "some_other_reason") + + snap := rec.Snapshot() + entries := snap.Rejects[1] + if len(entries) != 2 { + t.Fatalf("expected 2 reject reasons, got %d", len(entries)) + } + counts := map[string]uint{} + for _, e := range entries { + counts[e.Reason] = e.Count + } + if counts["validation_gate_rejected"] != 2 { + t.Fatalf("validation_gate_rejected count: got %d want 2", counts["validation_gate_rejected"]) + } + if counts["some_other_reason"] != 1 { + t.Fatalf("some_other_reason count: got %d want 1", counts["some_other_reason"]) + } +} + +func TestBoundedRecorder_RecordReject_PerReasonQuota(t *testing.T) { + rec := NewBoundedRecorderWithQuotas(8, 3, 4) + for i := 0; i < 10; i++ { + rec.RecordReject(1, "spam") + } + snap := rec.Snapshot() + got := snap.Rejects[1][0].Count + if got != 3 { + t.Fatalf("reject quota not enforced: got %d, want 3", got) + } +} + +func TestBoundedRecorder_RecordReject_PerReasonQuotasIndependent(t *testing.T) { + // A peer cannot saturate one reason to mask another -- each + // reason has its own quota counter. + rec := NewBoundedRecorderWithQuotas(8, 2, 4) + for i := 0; i < 10; i++ { + rec.RecordReject(1, "reason-A") + } + rec.RecordReject(1, "reason-B") + snap := rec.Snapshot() + counts := map[string]uint{} + for _, e := range snap.Rejects[1] { + counts[e.Reason] = e.Count + } + if counts["reason-A"] != 2 { + t.Fatalf("reason-A saturated at: got %d want 2", counts["reason-A"]) + } + if counts["reason-B"] != 1 { + t.Fatalf("reason-B counted independently: got %d want 1", counts["reason-B"]) + } +} + +func TestBoundedRecorder_RecordConflict_AccumulatesAndSaturates(t *testing.T) { + rec := NewBoundedRecorderWithQuotas(8, 8, 2) + rec.RecordConflict(7) + rec.RecordConflict(7) + rec.RecordConflict(7) + rec.RecordConflict(7) + snap := rec.Snapshot() + if got := snap.Conflicts[7]; got != 2 { + t.Fatalf("conflict count saturated at quota; got %d want 2", got) + } +} + +func TestBoundedRecorder_AllCategoriesPresentInSnapshot(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordOverflow(1) + rec.RecordReject(2, "validation_gate_rejected") + rec.RecordConflict(3) + snap := rec.Snapshot() + if snap.Overflows[1] == 0 { + t.Fatal("overflow not recorded") + } + if len(snap.Rejects[2]) == 0 { + t.Fatal("reject not recorded") + } + if snap.Conflicts[3] == 0 { + t.Fatal("conflict not recorded") + } +} + +func TestNoOpRecorder_AllCategoriesInert(t *testing.T) { + rec := NoOpRecorder() + for i := 0; i < 100; i++ { + rec.RecordOverflow(group.MemberIndex(i % 5)) + rec.RecordReject(group.MemberIndex(i%5), "spam") + rec.RecordConflict(group.MemberIndex(i % 5)) + } + snap := rec.Snapshot() + if len(snap.Overflows) != 0 || len(snap.Rejects) != 0 || len(snap.Conflicts) != 0 { + t.Fatalf("NoOp recorder must report empty snapshot; got %+v", snap) + } +} + +func TestRejectAndConflictQuotaConstants_MatchRFC(t *testing.T) { + if RejectQuotaDefault != 8 { + t.Fatalf("RFC-21 specifies reject quota = 8; constant is %d", RejectQuotaDefault) + } + if ConflictQuotaDefault != 4 { + t.Fatalf("RFC-21 specifies conflict quota = 4; constant is %d", ConflictQuotaDefault) + } +} diff --git a/pkg/frost/roast/attempt/evidence_recorder_test.go b/pkg/frost/roast/attempt/evidence_recorder_test.go new file mode 100644 index 0000000000..c36ba6abc3 --- /dev/null +++ b/pkg/frost/roast/attempt/evidence_recorder_test.go @@ -0,0 +1,141 @@ +package attempt + +import ( + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestNoOpRecorder_IsObservablyInert(t *testing.T) { + rec := NoOpRecorder() + for i := 0; i < 1000; i++ { + rec.RecordOverflow(group.MemberIndex(i%5 + 1)) + } + snap := rec.Snapshot() + if len(snap.Overflows) != 0 { + t.Fatalf( + "NoOp recorder must report zero overflows; got %d entries", + len(snap.Overflows), + ) + } +} + +func TestBoundedRecorder_CountsOverflowsBySender(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordOverflow(1) + rec.RecordOverflow(2) + rec.RecordOverflow(1) + + snap := rec.Snapshot() + if got := snap.Overflows[1]; got != 2 { + t.Fatalf("sender 1 overflow count: got %d want 2", got) + } + if got := snap.Overflows[2]; got != 1 { + t.Fatalf("sender 2 overflow count: got %d want 1", got) + } + if _, ok := snap.Overflows[3]; ok { + t.Fatal("sender 3 should have no entry") + } +} + +func TestBoundedRecorder_SaturatesAtQuota(t *testing.T) { + const quota uint = 4 + rec := NewBoundedRecorderWithQuota(quota) + + for i := uint(0); i < quota+10; i++ { + rec.RecordOverflow(1) + } + snap := rec.Snapshot() + if got := snap.Overflows[1]; got != quota { + t.Fatalf( + "overflow count must saturate at quota %d; got %d", + quota, got, + ) + } +} + +func TestBoundedRecorder_DefaultQuotaIs8(t *testing.T) { + rec := NewBoundedRecorder() + for i := 0; i < 100; i++ { + rec.RecordOverflow(1) + } + if got := rec.Snapshot().Overflows[1]; got != OverflowQuotaDefault { + t.Fatalf( + "default quota mismatch; got %d want %d", + got, OverflowQuotaDefault, + ) + } + if OverflowQuotaDefault != 8 { + t.Fatalf( + "RFC-21 Layer A specifies overflow quota = 8; constant is %d", + OverflowQuotaDefault, + ) + } +} + +func TestBoundedRecorder_SnapshotIsDeepCopy(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordOverflow(1) + rec.RecordOverflow(1) + + snap := rec.Snapshot() + snap.Overflows[1] = 999 + snap.Overflows[42] = 7 + + freshSnap := rec.Snapshot() + if got := freshSnap.Overflows[1]; got != 2 { + t.Fatalf( + "snapshot mutation leaked into recorder state: got %d want 2", + got, + ) + } + if _, ok := freshSnap.Overflows[42]; ok { + t.Fatal("snapshot mutation leaked a new key into recorder state") + } +} + +func TestBoundedRecorder_ConcurrentRecordersAreRaceSafe(t *testing.T) { + const ( + recordersPerSender = 8 + sendersN = 16 + recordsPerRecorder = 200 + ) + rec := NewBoundedRecorderWithQuota(uint(recordersPerSender * recordsPerRecorder * 10)) + + var wg sync.WaitGroup + for senderIdx := 1; senderIdx <= sendersN; senderIdx++ { + sender := group.MemberIndex(senderIdx) + for w := 0; w < recordersPerSender; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for n := 0; n < recordsPerRecorder; n++ { + rec.RecordOverflow(sender) + } + }() + } + } + wg.Wait() + + snap := rec.Snapshot() + for senderIdx := 1; senderIdx <= sendersN; senderIdx++ { + want := uint(recordersPerSender * recordsPerRecorder) + if got := snap.Overflows[group.MemberIndex(senderIdx)]; got != want { + t.Fatalf( + "sender %d concurrent count: got %d want %d", + senderIdx, got, want, + ) + } + } +} + +func TestNoOpRecorder_DistinctInstancesShareSemantics(t *testing.T) { + a := NoOpRecorder() + b := NoOpRecorder() + a.RecordOverflow(1) + b.RecordOverflow(2) + if len(a.Snapshot().Overflows) != 0 || len(b.Snapshot().Overflows) != 0 { + t.Fatal("NoOp instances must not retain state") + } +} diff --git a/pkg/frost/roast/bundle_aggregation_test.go b/pkg/frost/roast/bundle_aggregation_test.go new file mode 100644 index 0000000000..c3a6cb89ff --- /dev/null +++ b/pkg/frost/roast/bundle_aggregation_test.go @@ -0,0 +1,632 @@ +package roast + +import ( + "bytes" + "errors" + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// pickNonCoordinatorMember returns the first member of `set` that is +// not equal to `elected`. Fatals if no such member exists. Used by +// receiver-side tests that need a member distinct from the +// aggregator. +func pickNonCoordinatorMember( + t *testing.T, + set []group.MemberIndex, + elected group.MemberIndex, +) group.MemberIndex { + t.Helper() + for _, m := range set { + if m != elected { + return m + } + } + t.Fatalf("no non-coordinator member available; set=%v elected=%d", set, elected) + return 0 +} + +// signSnapshotForTest mints a fakeSigner signature on a snapshot and +// stores it on the snapshot's OperatorSignature field. Returns the +// snapshot for chaining. +func signSnapshotForTest( + t *testing.T, + snap *LocalEvidenceSnapshot, +) *LocalEvidenceSnapshot { + t.Helper() + signer := &fakeSigner{id: snap.SenderID()} + payload, err := snap.SignableBytes() + if err != nil { + t.Fatalf("canonical: %v", err) + } + sig, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + snap.OperatorSignature = sig + return snap +} + +// newSignedCoordinatorForMember returns an inMemoryCoordinator wired +// for the named member to act as self -- meaning AggregateBundle is +// only callable when that member is the elected coordinator for the +// attempt under test. +func newSignedCoordinatorForMember( + self group.MemberIndex, +) *inMemoryCoordinator { + return NewInMemoryCoordinatorWithSigning( + self, + &fakeSigner{id: self}, + fakeVerifier{}, + ).(*inMemoryCoordinator) +} + +func TestRecordEvidence_RejectsNilSnapshot(t *testing.T) { + c := newSignedCoordinatorForMember(0) + handle, err := c.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + if err := c.RecordEvidence(handle, nil); err == nil { + t.Fatal("expected nil snapshot error") + } +} + +func TestRecordEvidence_RejectsUnknownHandle(t *testing.T) { + c := newSignedCoordinatorForMember(0) + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot(1, pinnedContextHash, attempt.Evidence{})) + bogus := AttemptHandle{id: 999} + err := c.RecordEvidence(bogus, snap) + if !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } +} + +func TestRecordEvidence_RejectsContextHashMismatch(t *testing.T) { + c := newSignedCoordinatorForMember(0) + handle, err := c.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + // Build a snapshot bound to a *different* context hash. + wrongHash := [attempt.MessageDigestLength]byte{0xff} + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot(1, wrongHash, attempt.Evidence{})) + if err := c.RecordEvidence(handle, snap); !errors.Is(err, ErrAttemptContextMismatch) { + t.Fatalf("expected ErrAttemptContextMismatch, got %v", err) + } +} + +func TestRecordEvidence_RejectsBadSignature(t *testing.T) { + c := newSignedCoordinatorForMember(0) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + snap := NewLocalEvidenceSnapshot(1, ctx.Hash(), attempt.Evidence{}) + snap.OperatorSignature = []byte{0xff, 0xee} + err = c.RecordEvidence(handle, snap) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("expected ErrSignatureInvalid, got %v", err) + } +} + +func TestRecordEvidence_AcceptsValidSnapshotAndIsIdempotent(t *testing.T) { + c := newSignedCoordinatorForMember(0) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(1, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, snap); err != nil { + t.Fatalf("first record: %v", err) + } + // Identical re-submission must be idempotent. + if err := c.RecordEvidence(handle, snap); err != nil { + t.Fatalf("idempotent re-record: %v", err) + } +} + +func TestRecordEvidence_RejectsConflict(t *testing.T) { + c := newSignedCoordinatorForMember(0) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + first := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(1, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, first); err != nil { + t.Fatalf("first record: %v", err) + } + // Same sender, different evidence -> conflict. + conflicting := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(1, ctx.Hash(), attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{5: 3}, + }), + ) + if err := c.RecordEvidence(handle, conflicting); !errors.Is(err, ErrSnapshotConflict) { + t.Fatalf("expected ErrSnapshotConflict, got %v", err) + } +} + +func TestRecordEvidence_TracksSelfSubmission(t *testing.T) { + const self group.MemberIndex = 3 + c := newSignedCoordinatorForMember(self) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + selfSnap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(self, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, selfSnap); err != nil { + t.Fatalf("record self: %v", err) + } + record := c.attempts[handle.id] + if record.selfSubmission == nil { + t.Fatal("expected selfSubmission to be set") + } + if record.selfSubmission.SenderID() != self { + t.Fatalf("self submission member mismatch: got %d", record.selfSubmission.SenderID()) + } +} + +func TestAggregateBundle_RejectsNonAggregator(t *testing.T) { + // Two coordinator instances, both begin the same attempt. Only + // the elected one can aggregate. We force the election by + // building a context where SelectCoordinator will pick member 1. + c := NewInMemoryCoordinatorWithSigning(99, &fakeSigner{id: 99}, fakeVerifier{}).(*inMemoryCoordinator) + handle, err := c.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + // member 99 is not in the IncludedSet, so it cannot be the + // elected coordinator. + _, err = c.AggregateBundle(handle) + if !errors.Is(err, ErrNotAggregator) { + t.Fatalf("expected ErrNotAggregator, got %v", err) + } +} + +func TestAggregateBundle_BuildsSignedBundle(t *testing.T) { + // Pick the elected coordinator: run BeginAttempt once with a + // throwaway coordinator instance to discover the elected member, + // then build a real coordinator bound to that self. + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + c := newSignedCoordinatorForMember(elected) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + // Record snapshots from every included member. + for _, m := range ctx.IncludedSet { + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record %d: %v", m, err) + } + } + bundle, err := c.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + if len(bundle.Bundle) != len(ctx.IncludedSet) { + t.Fatalf( + "bundle size: got %d want %d", + len(bundle.Bundle), len(ctx.IncludedSet), + ) + } + for i := 1; i < len(bundle.Bundle); i++ { + if bundle.Bundle[i].SenderIDValue <= bundle.Bundle[i-1].SenderIDValue { + t.Fatalf("bundle not sorted ascending at %d", i) + } + } + if bundle.CoordinatorID() != elected { + t.Fatalf("bundle coordinator id %d != elected %d", bundle.CoordinatorID(), elected) + } + if len(bundle.CoordinatorSignature) == 0 { + t.Fatal("expected coordinator signature to be populated") + } + state, _ := c.State(handle) + if state != AttemptStateTransitioned { + t.Fatalf("expected state Transitioned, got %v", state) + } +} + +func TestAggregateBundle_ProducesDeterministicBundleAcrossOrderings(t *testing.T) { + // Two coordinators aggregate the same evidence in different + // arrival orders. The resulting bundles must be byte-identical + // after JSON marshal. + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + make := func( + t *testing.T, + recordOrder []group.MemberIndex, + ) []byte { + t.Helper() + c := newSignedCoordinatorForMember(elected) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + for _, m := range recordOrder { + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record %d: %v", m, err) + } + } + bundle, err := c.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + data, err := bundle.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + return data + } + ordering1 := []group.MemberIndex{1, 2, 3, 4, 5} + ordering2 := []group.MemberIndex{5, 3, 1, 4, 2} + a := make(t, ordering1) + b := make(t, ordering2) + if !bytes.Equal(a, b) { + t.Fatalf( + "identical evidence in different arrival order produced "+ + "different bundles:\n a=%s\n b=%s", + string(a), string(b), + ) + } +} + +func TestVerifyBundle_AcceptsValidBundle(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, err := aggregator.BeginAttempt(ctx) + if err != nil { + t.Fatalf("aggregator begin: %v", err) + } + for _, m := range ctx.IncludedSet { + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + ) + if err := aggregator.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record %d: %v", m, err) + } + } + bundle, err := aggregator.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + + // Receiver: a different coordinator instance bound to a + // non-coordinator member that has not submitted its own snapshot. + // The receiver must accept the bundle. + receiverID := pickNonCoordinatorMember(t, ctx.IncludedSet, elected) + receiver := NewInMemoryCoordinatorWithSigning( + receiverID, + &fakeSigner{id: receiverID}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + rh, err := receiver.BeginAttempt(ctx) + if err != nil { + t.Fatalf("receiver begin: %v", err) + } + if err := receiver.VerifyBundle(rh, bundle); err != nil { + t.Fatalf("expected verify success, got %v", err) + } +} + +func TestVerifyBundle_DetectsCensorship(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, err := aggregator.BeginAttempt(ctx) + if err != nil { + t.Fatalf("agg begin: %v", err) + } + // Record snapshots from every member EXCEPT receiverID. + receiverID := pickNonCoordinatorMember(t, ctx.IncludedSet, elected) + for _, m := range ctx.IncludedSet { + if m == receiverID { + continue + } + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + ) + if err := aggregator.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record: %v", err) + } + } + bundle, err := aggregator.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + + // Receiver: bound to receiverID, has submitted its own snapshot, + // but the coordinator chose to censor it. + receiver := NewInMemoryCoordinatorWithSigning( + receiverID, + &fakeSigner{id: receiverID}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + rh, err := receiver.BeginAttempt(ctx) + if err != nil { + t.Fatalf("receiver begin: %v", err) + } + selfSnap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(receiverID, ctx.Hash(), attempt.Evidence{}), + ) + if err := receiver.RecordEvidence(rh, selfSnap); err != nil { + t.Fatalf("receiver record self: %v", err) + } + err = receiver.VerifyBundle(rh, bundle) + if !errors.Is(err, ErrCensorshipDetected) { + t.Fatalf("expected ErrCensorshipDetected, got %v", err) + } +} + +func TestVerifyBundle_RetainsMutatedSelfSnapshotEvidenceBeforeSignatureFailure(t *testing.T) { + captured := captureEquivocationEvidence(t) + + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + receiverID := pickNonCoordinatorMember(t, ctx.IncludedSet, elected) + + receiver := NewInMemoryCoordinatorWithSigning( + receiverID, + &fakeSigner{id: receiverID}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + rh, err := receiver.BeginAttempt(ctx) + if err != nil { + t.Fatalf("receiver begin: %v", err) + } + selfSnap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(receiverID, ctx.Hash(), attempt.Evidence{}), + ) + if err := receiver.RecordEvidence(rh, selfSnap); err != nil { + t.Fatalf("receiver record self: %v", err) + } + + mutated := *selfSnap + mutated.OperatorSignature = bytes.Repeat([]byte{0xff}, len(mutated.OperatorSignature)) + mutated.bodyCache = nil + mutated.signaturePayloadCache = nil + mutated.wireEnvelope = nil + + contextHash := ctx.Hash() + bundle := &TransitionMessage{ + AttemptContextHash: append([]byte(nil), contextHash[:]...), + CoordinatorIDValue: uint32(elected), + Bundle: []LocalEvidenceSnapshot{mutated}, + } + payload, err := bundle.SignableBytes() + if err != nil { + t.Fatalf("bundle signable bytes: %v", err) + } + signature, err := (&fakeSigner{id: elected}).Sign(payload) + if err != nil { + t.Fatalf("sign bundle: %v", err) + } + bundle.CoordinatorSignature = signature + + err = receiver.VerifyBundle(rh, bundle) + if !errors.Is(err, ErrCensorshipDetected) { + t.Fatalf("expected ErrCensorshipDetected, got %v", err) + } + if len(*captured) != 1 { + t.Fatalf("expected 1 evidence event, got %d", len(*captured)) + } + evidence := (*captured)[0] + if evidence.Kind != EquivocationKindOwnSnapshotMutatedInBundle { + t.Fatalf("kind = %q", evidence.Kind) + } + wantSelf, _ := selfSnap.Marshal() + if !bytes.Equal(evidence.ExistingEnvelope, wantSelf) { + t.Fatal("existing envelope must be the self submission verbatim") + } + if len(evidence.ConflictingEnvelope) == 0 { + t.Fatal("conflicting envelope must carry the bundled snapshot") + } +} + +func TestVerifyBundle_DetectsCoordinatorSignatureForgery(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, _ := aggregator.BeginAttempt(ctx) + for _, m := range ctx.IncludedSet { + _ = aggregator.RecordEvidence(handle, signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + )) + } + bundle, _ := aggregator.AggregateBundle(handle) + // Tamper: re-sign the bundle as a different (non-elected) member. + const wrongSigner group.MemberIndex = 99 + bundle.CoordinatorIDValue = uint32(wrongSigner) + payload, _ := bundle.SignableBytes() + forged, _ := (&fakeSigner{id: wrongSigner}).Sign(payload) + bundle.CoordinatorSignature = forged + + receiver := NewInMemoryCoordinatorWithSigning( + 7, + &fakeSigner{id: 7}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + rh, _ := receiver.BeginAttempt(ctx) + err := receiver.VerifyBundle(rh, bundle) + if err == nil { + t.Fatal("expected verification failure") + } +} + +func TestVerifyBundle_DetectsSnapshotSignatureForgery(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, _ := aggregator.BeginAttempt(ctx) + for _, m := range ctx.IncludedSet { + _ = aggregator.RecordEvidence(handle, signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + )) + } + bundle, _ := aggregator.AggregateBundle(handle) + + // Tamper: replace one snapshot's signature with garbage. The + // bundle's coordinator signature still validates (since the + // canonical bundle bytes include the snapshot signature, an + // integrated bundle would have detected the change at the + // coordinator-signature layer). For this test we re-sign the + // bundle with the new garbage signature so the bundle-level + // signature appears valid but the snapshot signature does not. + bundle.Bundle[0].OperatorSignature = []byte{0xde, 0xad} + payload, _ := bundle.SignableBytes() + resign, _ := (&fakeSigner{id: elected}).Sign(payload) + bundle.CoordinatorSignature = resign + + receiver := NewInMemoryCoordinatorWithSigning( + 7, + &fakeSigner{id: 7}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + rh, _ := receiver.BeginAttempt(ctx) + err := receiver.VerifyBundle(rh, bundle) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("expected ErrSignatureInvalid, got %v", err) + } +} + +func TestVerifyBundle_RejectsAttemptContextMismatch(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, _ := aggregator.BeginAttempt(ctx) + for _, m := range ctx.IncludedSet { + _ = aggregator.RecordEvidence(handle, signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + )) + } + bundle, _ := aggregator.AggregateBundle(handle) + + receiver := NewInMemoryCoordinatorWithSigning( + 7, + &fakeSigner{id: 7}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + + // Receiver begins a different attempt context. + wrongCtx, _ := attempt.NewAttemptContext( + "different-session", + "key-group-test", + []byte{0xab, 0xcd, 0xef}, + [attempt.MessageDigestLength]byte{0x42}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + rh, _ := receiver.BeginAttempt(wrongCtx) + err := receiver.VerifyBundle(rh, bundle) + if !errors.Is(err, ErrAttemptContextMismatch) { + t.Fatalf("expected ErrAttemptContextMismatch, got %v", err) + } +} + +func TestVerifyBundle_RejectsNilMessage(t *testing.T) { + c := newSignedCoordinatorForMember(7) + handle, _ := c.BeginAttempt(newTestContext(t)) + if err := c.VerifyBundle(handle, nil); err == nil { + t.Fatal("expected error for nil message") + } +} + +func TestVerifyBundle_RejectsUnknownAttempt(t *testing.T) { + c := newSignedCoordinatorForMember(7) + bundle := buildValidTransitionMessage() + bogus := AttemptHandle{id: 999} + if err := c.VerifyBundle(bogus, bundle); !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } +} + +func TestCoordinator_ConcurrentRecordAndVerifyAreRaceSafe(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, _ := aggregator.BeginAttempt(ctx) + var wg sync.WaitGroup + for _, m := range ctx.IncludedSet { + wg.Add(1) + mLocal := m + go func() { + defer wg.Done() + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot(mLocal, ctx.Hash(), attempt.Evidence{})) + if err := aggregator.RecordEvidence(handle, snap); err != nil { + t.Errorf("concurrent record %d: %v", mLocal, err) + } + }() + } + wg.Wait() + bundle, err := aggregator.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate after concurrent records: %v", err) + } + if len(bundle.Bundle) != len(ctx.IncludedSet) { + t.Fatalf( + "bundle size after concurrent records: got %d want %d", + len(bundle.Bundle), len(ctx.IncludedSet), + ) + } +} diff --git a/pkg/frost/roast/coordinator.go b/pkg/frost/roast/coordinator.go new file mode 100644 index 0000000000..574131cf66 --- /dev/null +++ b/pkg/frost/roast/coordinator.go @@ -0,0 +1,46 @@ +package roast + +import ( + "fmt" + "math/rand" + "sort" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// SelectCoordinator deterministically picks a coordinator from the included +// members set for a given attempt. +// +// Selection is pseudo-random but stable across all participants that use the +// same attempt seed and attempt number. +// +// The RNG is intentionally deterministic and non-cryptographic. Callers must +// derive attemptSeed from group-agreed, non-grindable session inputs; if an +// adversary can choose or repeatedly grind those inputs, they can bias the +// coordinator selection by searching for a favorable shuffle. +func SelectCoordinator( + includedMembersIndexes []group.MemberIndex, + attemptSeed int64, + attemptNumber uint, +) (group.MemberIndex, error) { + if len(includedMembersIndexes) == 0 { + return 0, fmt.Errorf("cannot select coordinator from empty member set") + } + + members := make([]group.MemberIndex, len(includedMembersIndexes)) + copy(members, includedMembersIndexes) + + // Sort first to make sure selection result is independent from input order. + sort.Slice(members, func(i, j int) bool { + return members[i] < members[j] + }) + + // #nosec G404 (insecure random number source (rand)) + // Coordinator shuffling needs deterministic, not cryptographic randomness. + rng := rand.New(rand.NewSource(attemptSeed + int64(attemptNumber))) + rng.Shuffle(len(members), func(i, j int) { + members[i], members[j] = members[j], members[i] + }) + + return members[0], nil +} diff --git a/pkg/frost/roast/coordinator_seed_vectors_test.go b/pkg/frost/roast/coordinator_seed_vectors_test.go new file mode 100644 index 0000000000..8269cfd5b8 --- /dev/null +++ b/pkg/frost/roast/coordinator_seed_vectors_test.go @@ -0,0 +1,351 @@ +package roast + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strconv" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// coordinatorSeedVectorsPath is the cross-language conformance vector +// file for the normative coordinator-shuffle seed derivation (RFC-21 +// Annex A). The Rust signer carries a byte-identical copy at +// pkg/tbtc/signer/testdata/coordinator_seed_vectors.json and pins the +// same expectations; this file is the canonical source, regenerated +// from the Go implementation via TestRegenerateCoordinatorSeedVectors. +const coordinatorSeedVectorsPath = "testdata/coordinator_seed_vectors.json" + +type coordinatorSeedVectorFile struct { + Description string `json:"description"` + Vectors []coordinatorSeedVector `json:"vectors"` +} + +type coordinatorSeedVector struct { + Name string `json:"name"` + // KeyGroup is the canonical FROST key-group handle. Its UTF-8 + // bytes are the DkgGroupPublicKey input to DeriveAttemptSeed + // (for FrostTBTCSignerV1 material this is the lowercase hex + // encoding of the serialized group verifying key). + KeyGroup string `json:"keyGroup"` + SessionID string `json:"sessionID"` + MessageDigestHex string `json:"messageDigestHex"` + // IncludedMembers is the canonical ascending included set. + IncludedMembers []uint16 `json:"includedMembers"` + // AttemptNumber is the RFC-21 0-based attempt number used in the + // shuffle-source composition. + AttemptNumber uint32 `json:"attemptNumber"` + // WireAttemptNumber is the 1-based attempt number carried by the + // tbtc-signer FFI AttemptContext for the same logical attempt: + // always AttemptNumber + 1. + WireAttemptNumber uint32 `json:"wireAttemptNumber"` + // ExpectedShuffleSeedInt64 is the folded legacy int64 shuffle + // seed, encoded as a decimal string so JSON number precision + // cannot corrupt it. + ExpectedShuffleSeedInt64 string `json:"expectedShuffleSeedInt64"` + ExpectedCoordinator uint16 `json:"expectedCoordinator"` +} + +func loadCoordinatorSeedVectors(t *testing.T) coordinatorSeedVectorFile { + t.Helper() + raw, err := os.ReadFile(coordinatorSeedVectorsPath) + if err != nil { + t.Fatalf( + "cannot read %s (regenerate with ROAST_SEED_VECTORS_REGEN=1): %v", + coordinatorSeedVectorsPath, + err, + ) + } + var file coordinatorSeedVectorFile + if err := json.Unmarshal(raw, &file); err != nil { + t.Fatalf("cannot parse %s: %v", coordinatorSeedVectorsPath, err) + } + if len(file.Vectors) == 0 { + t.Fatalf("%s contains no vectors", coordinatorSeedVectorsPath) + } + return file +} + +func deriveCoordinatorSeedVectorOutputs( + t *testing.T, + vector coordinatorSeedVector, +) (int64, group.MemberIndex) { + t.Helper() + decoded, err := hex.DecodeString(vector.MessageDigestHex) + if err != nil || len(decoded) != attempt.MessageDigestLength { + t.Fatalf( + "vector %q: messageDigestHex must decode to exactly %d bytes", + vector.Name, + attempt.MessageDigestLength, + ) + } + var digest [attempt.MessageDigestLength]byte + copy(digest[:], decoded) + + seed := attempt.DeriveAttemptSeed( + []byte(vector.KeyGroup), + vector.SessionID, + digest, + ) + folded := foldAttemptSeed(seed) + + members := make([]group.MemberIndex, 0, len(vector.IncludedMembers)) + for _, m := range vector.IncludedMembers { + members = append(members, group.MemberIndex(m)) + } + coordinator, err := SelectCoordinator( + members, + folded, + uint(vector.AttemptNumber), + ) + if err != nil { + t.Fatalf("vector %q: SelectCoordinator: %v", vector.Name, err) + } + return folded, coordinator +} + +// TestCoordinatorSeedDerivation_ConformanceVectors pins the normative +// RFC-21 Annex A derivation end to end: +// +// AttemptSeed32 = SHA256(KeyGroupBytes || SessionID || MessageDigest) +// ShuffleSeed_i64 = int64_be(AttemptSeed32[0:8]) +// SourceSeed = ShuffleSeed_i64 + int64(AttemptNumber) // 0-based +// Coordinator = GoMathRandShuffle(sorted(IncludedSet), SourceSeed)[0] +// +// Any semantic change to DeriveAttemptSeed, foldAttemptSeed, or +// SelectCoordinator fails this suite, and the byte-identical vector +// copy in the Rust signer fails its mirror test -- either side +// drifting breaks its own CI rather than fracturing coordinator +// agreement in a mixed deployment. +func TestCoordinatorSeedDerivation_ConformanceVectors(t *testing.T) { + file := loadCoordinatorSeedVectors(t) + + sawNegativeSeed := false + for _, vector := range file.Vectors { + vector := vector + t.Run(vector.Name, func(t *testing.T) { + if vector.WireAttemptNumber != vector.AttemptNumber+1 { + t.Fatalf( + "wireAttemptNumber [%d] must equal attemptNumber+1 [%d]", + vector.WireAttemptNumber, + vector.AttemptNumber+1, + ) + } + + folded, coordinator := deriveCoordinatorSeedVectorOutputs(t, vector) + + expectedSeed, err := strconv.ParseInt( + vector.ExpectedShuffleSeedInt64, 10, 64, + ) + if err != nil { + t.Fatalf( + "expectedShuffleSeedInt64 %q is not a valid int64: %v", + vector.ExpectedShuffleSeedInt64, err, + ) + } + if folded != expectedSeed { + t.Fatalf( + "shuffle seed mismatch: derived %d, vector pins %d", + folded, expectedSeed, + ) + } + if coordinator != group.MemberIndex(vector.ExpectedCoordinator) { + t.Fatalf( + "coordinator mismatch: derived %d, vector pins %d", + coordinator, vector.ExpectedCoordinator, + ) + } + }) + if vector.ExpectedShuffleSeedInt64 != "" && vector.ExpectedShuffleSeedInt64[0] == '-' { + sawNegativeSeed = true + } + } + + // The legacy seed is a reinterpreted uint64, so roughly half of + // all derivations are negative. Keep at least one negative pin in + // the file so a sign-handling regression (e.g. an unsigned port) + // cannot pass. + if !sawNegativeSeed { + t.Fatal("vector file must pin at least one negative shuffle seed") + } +} + +// TestRegenerateCoordinatorSeedVectors rewrites the conformance +// vector file from the deterministic input matrix below using the +// current Go implementation. Guarded behind an env flag so it never +// rewrites during normal CI: +// +// ROAST_SEED_VECTORS_REGEN=1 go test ./pkg/frost/roast -run TestRegenerateCoordinatorSeedVectors +// +// After regenerating, copy the file byte-identically to +// pkg/tbtc/signer/testdata/coordinator_seed_vectors.json on the +// signer branch. +// +// Regenerating is a protocol-change event, not a refresh: the file +// pins the normative RFC-21 Annex A behaviour, so a run that produces +// different bytes means the derivation changed and requires an +// Annex A update in the same change plus a mixed-fleet rollout note. +// Both language suites passing after a dual regen is NOT evidence of +// compatibility with already-deployed engines. +func TestRegenerateCoordinatorSeedVectors(t *testing.T) { + if os.Getenv("ROAST_SEED_VECTORS_REGEN") != "1" { + t.Skip("set ROAST_SEED_VECTORS_REGEN=1 to regenerate the vector file") + } + + productionKeyGroup := "024d79b696a25e478a1c747fcaad380a" + + "ddbd8b2ef7c333126ab2e2c3b2533b7df2" + opaqueKeyGroup := "roast-vector-opaque-key-group-handle" + + digestA := make([]byte, attempt.MessageDigestLength) + for i := range digestA { + digestA[i] = byte(i) + } + digestB := make([]byte, attempt.MessageDigestLength) + for i := range digestB { + digestB[i] = byte(0xf0 - i) + } + + wideSet := make([]uint16, 0, 100) + for m := uint16(1); m <= 100; m++ { + wideSet = append(wideSet, m) + } + + type vectorInput struct { + name string + keyGroup string + sessionID string + digest []byte + members []uint16 + attempt uint32 + } + inputs := []vectorInput{ + { + name: "five-members-attempt-0", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 0, + }, + { + name: "five-members-attempt-1", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 1, + }, + { + name: "five-members-attempt-5", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 5, + }, + { + name: "sparse-members-attempt-0", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{2, 7, 9, 11}, + attempt: 0, + }, + { + name: "different-session-changes-seed", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-2", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 0, + }, + { + name: "different-digest-changes-seed", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestB, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 0, + }, + { + name: "opaque-key-group-handle", + keyGroup: opaqueKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 0, + }, + { + name: "production-group-size-attempt-0", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: wideSet, + attempt: 0, + }, + { + name: "production-group-size-attempt-3", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestB, + members: wideSet, + attempt: 3, + }, + { + name: "opaque-key-group-wide-set-attempt-7", + keyGroup: opaqueKeyGroup, + sessionID: "session-roast-seed-vector-2", + digest: digestB, + members: wideSet, + attempt: 7, + }, + } + + file := coordinatorSeedVectorFile{ + Description: "Cross-language conformance vectors for the RFC-21 Annex A " + + "coordinator-shuffle seed derivation: ShuffleSeed_i64 = " + + "int64_be(SHA256(KeyGroupBytes || SessionID || MessageDigest)[0:8]); " + + "coordinator = GoMathRandShuffle(sorted(IncludedMembers), " + + "ShuffleSeed_i64 + int64(AttemptNumber))[0] with the 0-based RFC-21 " + + "AttemptNumber. wireAttemptNumber is the 1-based tbtc-signer FFI " + + "encoding of the same attempt. Canonical copy: " + + "pkg/frost/roast/testdata/coordinator_seed_vectors.json (Go); " + + "mirrored byte-identically to " + + "pkg/tbtc/signer/testdata/coordinator_seed_vectors.json (Rust).", + } + + for _, input := range inputs { + var digest [attempt.MessageDigestLength]byte + copy(digest[:], input.digest) + vector := coordinatorSeedVector{ + Name: input.name, + KeyGroup: input.keyGroup, + SessionID: input.sessionID, + MessageDigestHex: hex.EncodeToString(input.digest), + IncludedMembers: input.members, + AttemptNumber: input.attempt, + WireAttemptNumber: input.attempt + 1, + } + folded, coordinator := deriveCoordinatorSeedVectorOutputs(t, vector) + vector.ExpectedShuffleSeedInt64 = fmt.Sprintf("%d", folded) + vector.ExpectedCoordinator = uint16(coordinator) + file.Vectors = append(file.Vectors, vector) + } + + encoded, err := json.MarshalIndent(file, "", " ") + if err != nil { + t.Fatalf("encode vector file: %v", err) + } + encoded = append(encoded, '\n') + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatalf("create testdata dir: %v", err) + } + if err := os.WriteFile(coordinatorSeedVectorsPath, encoded, 0o644); err != nil { + t.Fatalf("write vector file: %v", err) + } + t.Logf("regenerated %s with %d vectors", coordinatorSeedVectorsPath, len(file.Vectors)) +} diff --git a/pkg/frost/roast/coordinator_shuffle_corpus_test.go b/pkg/frost/roast/coordinator_shuffle_corpus_test.go new file mode 100644 index 0000000000..6923c6bed4 --- /dev/null +++ b/pkg/frost/roast/coordinator_shuffle_corpus_test.go @@ -0,0 +1,277 @@ +package roast + +import ( + "encoding/json" + "fmt" + "math" + "math/rand" + "os" + "strconv" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// coordinatorShuffleCorpusPath is the cross-language differential +// corpus for the legacy Go math/rand coordinator shuffle. Where +// testdata/coordinator_seed_vectors.json pins the full RFC-21 Annex A +// derivation on a handful of hand-picked vectors, this corpus pins +// the shuffle itself -- SelectCoordinator semantics over explicit +// int64 seeds -- across hundreds of generated cases and the integer +// boundary values where a port is most likely to diverge (negative +// seeds, i64 extremes, wrapping seed+attempt composition, unsorted +// inputs, 1..255-member sets). +// +// The Rust signer carries a byte-identical copy at +// pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json consumed +// by go_math_rand.rs tests; either side drifting fails its own suite. +// Regenerate here with ROAST_SHUFFLE_CORPUS_REGEN=1 and re-copy. +const coordinatorShuffleCorpusPath = "testdata/coordinator_shuffle_corpus.json" + +type coordinatorShuffleCorpusFile struct { + Description string `json:"description"` + Cases []coordinatorShuffleCase `json:"cases"` +} + +type coordinatorShuffleCase struct { + Name string `json:"name"` + // SeedInt64 is the legacy shuffle seed as a decimal string so + // JSON number precision cannot corrupt it. + SeedInt64 string `json:"seedInt64"` + // AttemptNumber is the 0-based attempt composed into the + // rand.Source seed as seed + int64(attemptNumber) (two's- + // complement wrapping). + AttemptNumber uint32 `json:"attemptNumber"` + // Members is the included set in the order passed to the + // shuffle. Deliberately not always sorted: both implementations + // must sort internally, and unsorted cases pin that. + Members []uint16 `json:"members"` + ExpectedCoordinator uint16 `json:"expectedCoordinator"` +} + +func loadCoordinatorShuffleCorpus(t *testing.T) coordinatorShuffleCorpusFile { + t.Helper() + raw, err := os.ReadFile(coordinatorShuffleCorpusPath) + if err != nil { + t.Fatalf( + "cannot read %s (regenerate with ROAST_SHUFFLE_CORPUS_REGEN=1): %v", + coordinatorShuffleCorpusPath, + err, + ) + } + var file coordinatorShuffleCorpusFile + if err := json.Unmarshal(raw, &file); err != nil { + t.Fatalf("cannot parse %s: %v", coordinatorShuffleCorpusPath, err) + } + if len(file.Cases) == 0 { + t.Fatalf("%s contains no cases", coordinatorShuffleCorpusPath) + } + return file +} + +func runCoordinatorShuffleCase( + t *testing.T, + shuffleCase coordinatorShuffleCase, +) group.MemberIndex { + t.Helper() + seed, err := strconv.ParseInt(shuffleCase.SeedInt64, 10, 64) + if err != nil { + t.Fatalf( + "case %q: seedInt64 %q is not a valid int64: %v", + shuffleCase.Name, shuffleCase.SeedInt64, err, + ) + } + members := make([]group.MemberIndex, 0, len(shuffleCase.Members)) + for _, m := range shuffleCase.Members { + if m == 0 || m > math.MaxUint8 { + t.Fatalf( + "case %q: member %d outside group.MemberIndex range", + shuffleCase.Name, m, + ) + } + members = append(members, group.MemberIndex(m)) + } + coordinator, err := SelectCoordinator( + members, + seed, + uint(shuffleCase.AttemptNumber), + ) + if err != nil { + t.Fatalf("case %q: SelectCoordinator: %v", shuffleCase.Name, err) + } + return coordinator +} + +// TestCoordinatorShuffle_DifferentialCorpus replays the generated +// corpus against SelectCoordinator. The Rust go_math_rand port runs +// the identical corpus, so any semantic drift in either shuffle -- +// source seeding, Fisher-Yates order, int31n bounds, sign handling, +// wrapping composition, internal sorting -- fails the drifting side's +// own unit suite instead of fracturing coordinator agreement in a +// mixed deployment. +func TestCoordinatorShuffle_DifferentialCorpus(t *testing.T) { + file := loadCoordinatorShuffleCorpus(t) + + for _, shuffleCase := range file.Cases { + coordinator := runCoordinatorShuffleCase(t, shuffleCase) + if coordinator != group.MemberIndex(shuffleCase.ExpectedCoordinator) { + t.Fatalf( + "case %q: coordinator mismatch: derived %d, corpus pins %d", + shuffleCase.Name, coordinator, shuffleCase.ExpectedCoordinator, + ) + } + } +} + +// TestRegenerateCoordinatorShuffleCorpus rewrites the corpus from the +// deterministic case matrix below using the current Go +// implementation. Guarded behind an env flag so it never rewrites +// during normal CI: +// +// ROAST_SHUFFLE_CORPUS_REGEN=1 go test ./pkg/frost/roast -run TestRegenerateCoordinatorShuffleCorpus +// +// After regenerating, copy the file byte-identically to +// pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json on the +// signer branch. +// +// Regenerating is a protocol-change event, not a refresh: the corpus +// pins the legacy math/rand shuffle semantics shared with the Rust +// port, so a run that produces different bytes means the shuffle +// changed and deployed engines would disagree on coordinator +// rotation. Both language suites passing after a dual regen is NOT +// evidence of compatibility with already-deployed engines. +func TestRegenerateCoordinatorShuffleCorpus(t *testing.T) { + if os.Getenv("ROAST_SHUFFLE_CORPUS_REGEN") != "1" { + t.Skip("set ROAST_SHUFFLE_CORPUS_REGEN=1 to regenerate the corpus file") + } + + cases := make([]coordinatorShuffleCase, 0, 600) + + // Boundary block: integer extremes, sign boundaries, the + // wrapping seed+attempt composition, and the historical + // cross-language pin seed from PR #4026. + // + // +/-MaxInt32 are the rand.NewSource seed-normalization collision: + // Go reduces the source seed mod (2^31 - 1) and maps 0 to 89482311, + // so +/-(2^31 - 1) seed the generator identically to 0. Pinning them + // catches a port that special-cases literal 0 but skips the modulo. + boundarySeeds := []int64{ + 0, + 1, + -1, + math.MaxInt64, + math.MinInt64, + math.MaxInt64 - 3, + math.MinInt64 + 3, + math.MaxInt32, // == 2^31 - 1; normalizes to the seed-0 state + -math.MaxInt32, // negative wrap then the same collision + 6879463052285329321, + -6879463052285329321, + } + boundaryAttempts := []uint32{0, 1, 7, math.MaxUint32} + boundarySets := [][]uint16{ + {1}, + {1, 2}, + {2, 1}, // unsorted: pins internal sorting + {5, 4, 3, 2, 1}, // reverse order + {1, 2, 3, 4, 5}, + {7, 2, 11, 9}, // sparse, unsorted + } + for _, seed := range boundarySeeds { + for _, attemptNumber := range boundaryAttempts { + for setIndex, members := range boundarySets { + cases = append(cases, coordinatorShuffleCase{ + Name: fmt.Sprintf( + "boundary-seed-%d-attempt-%d-set-%d", + seed, attemptNumber, setIndex, + ), + SeedInt64: strconv.FormatInt(seed, 10), + AttemptNumber: attemptNumber, + Members: members, + }) + } + } + } + + // Generated block: deterministic pseudo-random sweep over set + // sizes 1..255 (every member index value reachable), seeds across + // the full int64 range, attempts across realistic and large + // values. The generator RNG seed is fixed; it only chooses the + // case inputs and has no parity significance itself. + generatorRng := rand.New(rand.NewSource(0x5EED_C0DE)) + memberPool := make([]uint16, 255) + for i := range memberPool { + memberPool[i] = uint16(i + 1) + } + for caseIndex := 0; caseIndex < 384; caseIndex++ { + // Bias towards small sets (production-realistic) while still + // sweeping up to the full 255. + var setSize int + switch caseIndex % 4 { + case 0: + setSize = 1 + generatorRng.Intn(5) + case 1: + setSize = 6 + generatorRng.Intn(46) + case 2: + setSize = 51 + generatorRng.Intn(50) + default: + setSize = 101 + generatorRng.Intn(155) + } + + generatorRng.Shuffle(len(memberPool), func(i, j int) { + memberPool[i], memberPool[j] = memberPool[j], memberPool[i] + }) + members := make([]uint16, setSize) + copy(members, memberPool[:setSize]) + + seed := int64(generatorRng.Uint64()) + var attemptNumber uint32 + switch caseIndex % 3 { + case 0: + attemptNumber = uint32(generatorRng.Intn(8)) + case 1: + attemptNumber = uint32(generatorRng.Intn(1 << 16)) + default: + attemptNumber = generatorRng.Uint32() + } + + cases = append(cases, coordinatorShuffleCase{ + Name: fmt.Sprintf("generated-%03d-size-%d", caseIndex, setSize), + SeedInt64: strconv.FormatInt(seed, 10), + AttemptNumber: attemptNumber, + Members: members, + }) + } + + for caseIndex := range cases { + coordinator := runCoordinatorShuffleCase(t, cases[caseIndex]) + cases[caseIndex].ExpectedCoordinator = uint16(coordinator) + } + + file := coordinatorShuffleCorpusFile{ + Description: "Cross-language differential corpus for the legacy Go math/rand " + + "coordinator shuffle (SelectCoordinator / go_math_rand.rs " + + "select_coordinator_identifier): source seed = seedInt64 + " + + "int64(attemptNumber) with two's-complement wrapping; members sorted " + + "ascending internally before the Fisher-Yates shuffle; first element " + + "after shuffling is the coordinator. Canonical copy: " + + "pkg/frost/roast/testdata/coordinator_shuffle_corpus.json (Go); " + + "mirrored byte-identically to " + + "pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json (Rust). " + + "Regenerate with ROAST_SHUFFLE_CORPUS_REGEN=1.", + Cases: cases, + } + + encoded, err := json.Marshal(file) + if err != nil { + t.Fatalf("encode corpus file: %v", err) + } + encoded = append(encoded, '\n') + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatalf("create testdata dir: %v", err) + } + if err := os.WriteFile(coordinatorShuffleCorpusPath, encoded, 0o644); err != nil { + t.Fatalf("write corpus file: %v", err) + } + t.Logf("regenerated %s with %d cases", coordinatorShuffleCorpusPath, len(cases)) +} diff --git a/pkg/frost/roast/coordinator_state.go b/pkg/frost/roast/coordinator_state.go new file mode 100644 index 0000000000..b2a583eab2 --- /dev/null +++ b/pkg/frost/roast/coordinator_state.go @@ -0,0 +1,537 @@ +package roast + +import ( + "bytes" + "errors" + "fmt" + "sort" + "sync" + "sync/atomic" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// AttemptState is the phase an attempt is in within the Coordinator +// state machine. The lifecycle is monotonic; from AttemptStateCollecting +// it branches by outcome: +// +// AttemptStatePending -> AttemptStateCollecting, then either +// - AttemptStateAggregating -> AttemptStateTransitioned (failure: +// the coordinator emitted a TransitionMessage; AggregateBundle), or +// - AttemptStateSucceeded (success: a final signature was aggregated; +// no transition message is needed; MarkSucceeded). +// +// AttemptStateSucceeded means the attempt produced a final signature. +// AttemptStateTransitioned means the attempt timed out or hit an +// unrecoverable reject and the coordinator emitted a +// TransitionMessage that drives the next attempt's context. Phase 3.1 +// (this file) introduces the state surface only; later phases drive +// the transitions. +type AttemptState uint8 + +const ( + // AttemptStatePending is the zero value -- not a real state, used + // only as the default-initialised "unknown" sentinel returned with + // ErrUnknownAttempt. + AttemptStatePending AttemptState = iota + // AttemptStateCollecting -- the attempt has been started, the + // included set is fixed, and the coordinator is accepting signed + // evidence snapshots from peers. + AttemptStateCollecting + // AttemptStateAggregating -- the coordinator has stopped + // accepting evidence and is building the TransitionMessage + // bundle. + AttemptStateAggregating + // AttemptStateSucceeded -- the attempt produced a final + // signature; no transition message is needed. + AttemptStateSucceeded + // AttemptStateTransitioned -- the attempt timed out or failed + // and the coordinator has emitted a TransitionMessage; the next + // attempt's context can now be computed by NextAttempt. + AttemptStateTransitioned +) + +func (s AttemptState) String() string { + switch s { + case AttemptStatePending: + return "pending" + case AttemptStateCollecting: + return "collecting" + case AttemptStateAggregating: + return "aggregating" + case AttemptStateSucceeded: + return "succeeded" + case AttemptStateTransitioned: + return "transitioned" + default: + return fmt.Sprintf("unknown(%d)", uint8(s)) + } +} + +// AttemptHandle is the opaque per-attempt identity returned by +// Coordinator.BeginAttempt. Handles are not interchangeable across +// coordinator instances: a handle minted by coordinator A cannot be +// passed to coordinator B. Callers must not mutate handles directly. +type AttemptHandle struct { + id uint64 + contextHash [attempt.MessageDigestLength]byte +} + +// ContextHash returns the canonical AttemptContext.Hash() value bound +// to this handle. Useful for cross-checking a handle against a +// context after the fact. +func (h AttemptHandle) ContextHash() [attempt.MessageDigestLength]byte { + return h.contextHash +} + +// Coordinator is the ROAST coordinator state machine introduced by +// RFC-21 Phase 3. It owns per-attempt state, the deterministic +// participant selection (via the existing SelectCoordinator helper), +// signed-evidence aggregation, transition-message construction, and +// -- in Phase 3.4 -- the NextAttempt policy. +// +// Phase 3.1 introduced BeginAttempt, State, and SelectedCoordinator. +// Phase 3.3 (this commit) adds RecordEvidence, AggregateBundle, and +// VerifyBundle. +// Phase 3.4 will add NextAttempt. +// +// Implementations must be safe for concurrent calls from multiple +// goroutines; production keep-core code paths are network-driven. +type Coordinator interface { + // BeginAttempt initialises tracking for a new attempt with the + // given context. It selects the attempt's coordinator + // deterministically from ctx.IncludedSet via SelectCoordinator + // (with the legacy int64 seed produced by foldAttemptSeed) and + // stores the result on the returned handle. + BeginAttempt(ctx attempt.AttemptContext) (AttemptHandle, error) + // State returns the current AttemptState for the given handle. + // Returns ErrUnknownAttempt if the handle was not produced by + // this Coordinator instance. + State(handle AttemptHandle) (AttemptState, error) + // SelectedCoordinator returns the member elected as coordinator + // for the attempt identified by the handle. Returns + // ErrUnknownAttempt if the handle is not tracked. + SelectedCoordinator(handle AttemptHandle) (group.MemberIndex, error) + // RecordEvidence stores a peer's signed LocalEvidenceSnapshot + // against the named attempt. The snapshot is validated for + // structural correctness, its OperatorSignature is verified + // against the configured SignatureVerifier, and its + // AttemptContextHash is checked to match the handle's bound + // context. First-write-wins / equal-or-reject semantics apply: + // a peer that re-submits the same byte-identical snapshot is + // idempotent; a peer that mutates its snapshot returns an error + // without overwriting the originally accepted one. + RecordEvidence(handle AttemptHandle, snapshot *LocalEvidenceSnapshot) error + // AggregateBundle is called by the elected coordinator's node + // to produce a TransitionMessage from the accumulated evidence + // snapshots. The bundle is sorted ascending by SenderID, signed + // with the coordinator's Signer, and the attempt state is + // transitioned to AttemptStateAggregating then + // AttemptStateTransitioned. + // + // Returns ErrNotAggregator if the caller is not the elected + // coordinator for the attempt (the Coordinator's selfMember + // must equal SelectedCoordinator(handle)). + AggregateBundle(handle AttemptHandle) (*TransitionMessage, error) + // MarkSucceeded transitions a live (Collecting) attempt to + // AttemptStateSucceeded after this node has aggregated a final + // signature for it. It is the success counterpart to AggregateBundle's + // failure transition: without it a successful attempt would remain + // Collecting and the cleanup path would emit a spurious + // TransitionMessage for an attempt that actually completed. Any node + // may mark its OWN attempt succeeded (unlike AggregateBundle, success + // is not coordinator-only). Returns ErrUnknownAttempt for an untracked + // handle and ErrAttemptStateInvalid if the attempt is not Collecting. + MarkSucceeded(handle AttemptHandle) error + // VerifyBundle is called by every receiver of a + // TransitionMessage. It validates the structural invariants of + // the bundle, verifies the coordinator-level signature against + // the attempt's elected coordinator, verifies each contained + // snapshot's operator signature, and -- if the receiver has + // already submitted its own snapshot via RecordEvidence with + // the local Signer applied -- verifies that the receiver's own + // snapshot is present and byte-identical in the bundle + // (censorship detection). + // + // Returns ErrCensorshipDetected when the receiver's own + // submitted snapshot is missing or mutated. Returns + // ErrSignatureInvalid when any signature fails verification. + VerifyBundle(handle AttemptHandle, msg *TransitionMessage) error + // NextAttempt computes the deterministic next AttemptContext + // from a verified TransitionMessage. Callers MUST call + // VerifyBundle before NextAttempt; NextAttempt does not + // re-verify signatures. + // + // threshold is the FROST signing threshold t for the key group; + // it is constant across attempts within a session. A threshold + // of zero disables the infeasibility check (test seam). + // + // dkgGroupPublicKey is the DKG-validated group public key from + // the FFI signer material (RFC-21 Decision 2). It is passed + // here so two honest signers derive the same AttemptSeed for + // the next attempt. + // + // Returns ErrAttemptInfeasible when the next IncludedSet would + // drop below threshold. + NextAttempt( + handle AttemptHandle, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, + ) (attempt.AttemptContext, error) +} + +// ErrNotAggregator is returned by AggregateBundle when the caller +// is not the elected coordinator for the named attempt. +var ErrNotAggregator = errors.New( + "coordinator: caller is not the elected coordinator for this attempt", +) + +// ErrAttemptStateInvalid is returned when an operation is requested +// against an attempt in a state that does not permit it (e.g. +// AggregateBundle on an attempt already transitioned, or +// RecordEvidence on an attempt past Collecting). +var ErrAttemptStateInvalid = errors.New("coordinator: attempt state does not permit operation") + +// ErrAttemptContextMismatch is returned when a snapshot's +// AttemptContextHash does not match the handle's bound context. +var ErrAttemptContextMismatch = errors.New("coordinator: snapshot attempt context hash does not match attempt") + +// ErrSnapshotConflict is returned by RecordEvidence when a peer +// re-submits a snapshot whose canonical bytes differ from the +// previously-accepted snapshot for that peer in this attempt. The +// originally accepted snapshot is retained; the new submission is +// rejected (first-write-wins). +var ErrSnapshotConflict = errors.New("coordinator: snapshot conflicts with previously recorded one (first-write-wins)") + +// ErrUnknownAttempt indicates an AttemptHandle does not correspond to +// any attempt tracked by this Coordinator. Either the handle was +// minted by a different coordinator instance, or the attempt has +// been pruned. +var ErrUnknownAttempt = errors.New("coordinator: unknown attempt handle") + +// NewInMemoryCoordinator returns a Coordinator that tracks attempts +// in-process with no operator-key signing wired in (NoOpSigner + +// NoOpSignatureVerifier). Suitable for tests that exercise only the +// structural state-machine surface; bundle verification will accept +// any signature. +// +// Production Phase-4 callers should use +// NewInMemoryCoordinatorWithSigning to inject the node's real +// operator-key signer and the network's member-key-resolving +// verifier. +func NewInMemoryCoordinator() Coordinator { + return NewInMemoryCoordinatorWithSigning( + 0, + NoOpSigner(), + NoOpSignatureVerifier(), + ) +} + +// NewInMemoryCoordinatorWithSigning returns an in-memory Coordinator +// bound to the node's own member index, the node's operator-key +// Signer, and a SignatureVerifier capable of resolving every member's +// operator key. selfMember = 0 disables the censorship-detection +// check in VerifyBundle (Phase 3.3 default for unit tests; Phase 4 +// always supplies a non-zero value). +func NewInMemoryCoordinatorWithSigning( + selfMember group.MemberIndex, + signer Signer, + verifier SignatureVerifier, +) Coordinator { + return &inMemoryCoordinator{ + attempts: map[uint64]*attemptRecord{}, + selfMember: selfMember, + signer: signer, + verifier: verifier, + } +} + +type attemptRecord struct { + handle AttemptHandle + context attempt.AttemptContext + coordinator group.MemberIndex + state AttemptState + snapshots map[group.MemberIndex]*LocalEvidenceSnapshot + selfSubmission *LocalEvidenceSnapshot +} + +type inMemoryCoordinator struct { + mu sync.Mutex + nextID atomic.Uint64 + attempts map[uint64]*attemptRecord + selfMember group.MemberIndex + signer Signer + verifier SignatureVerifier +} + +func (c *inMemoryCoordinator) BeginAttempt( + ctx attempt.AttemptContext, +) (AttemptHandle, error) { + if len(ctx.IncludedSet) == 0 { + return AttemptHandle{}, fmt.Errorf( + "coordinator: cannot begin attempt with empty included set", + ) + } + coord, err := SelectCoordinator( + ctx.IncludedSet, + foldAttemptSeed(ctx.AttemptSeed), + uint(ctx.AttemptNumber), + ) + if err != nil { + return AttemptHandle{}, fmt.Errorf( + "coordinator: selection failed: %w", + err, + ) + } + handle := AttemptHandle{ + id: c.nextID.Add(1), + contextHash: ctx.Hash(), + } + record := &attemptRecord{ + handle: handle, + context: ctx, + coordinator: coord, + state: AttemptStateCollecting, + snapshots: map[group.MemberIndex]*LocalEvidenceSnapshot{}, + } + c.mu.Lock() + defer c.mu.Unlock() + c.attempts[handle.id] = record + return handle, nil +} + +func (c *inMemoryCoordinator) State( + handle AttemptHandle, +) (AttemptState, error) { + c.mu.Lock() + defer c.mu.Unlock() + record, ok := c.attempts[handle.id] + if !ok { + return AttemptStatePending, ErrUnknownAttempt + } + return record.state, nil +} + +func (c *inMemoryCoordinator) SelectedCoordinator( + handle AttemptHandle, +) (group.MemberIndex, error) { + c.mu.Lock() + defer c.mu.Unlock() + record, ok := c.attempts[handle.id] + if !ok { + return 0, ErrUnknownAttempt + } + return record.coordinator, nil +} + +func (c *inMemoryCoordinator) RecordEvidence( + handle AttemptHandle, + snapshot *LocalEvidenceSnapshot, +) error { + if snapshot == nil { + return errors.New("coordinator: snapshot is nil") + } + if err := snapshot.Validate(); err != nil { + return fmt.Errorf("coordinator: snapshot invalid: %w", err) + } + if err := verifySnapshotSignature(c.verifier, snapshot); err != nil { + return fmt.Errorf("coordinator: %w", err) + } + + // Emit any equivocation evidence AFTER c.mu is released: a registered + // observer is host telemetry (possibly a blocking write) and must not + // run while the coordinator state mutex is held. The evidence value is + // fully materialized (bytes copied) under the lock; emission only reads + // that copy. Registered before the unlock defer so it runs after it. + var pendingEvidence *EquivocationEvidence + defer func() { + if pendingEvidence != nil { + emitEquivocationEvidence(*pendingEvidence) + } + }() + + c.mu.Lock() + defer c.mu.Unlock() + record, ok := c.attempts[handle.id] + if !ok { + return ErrUnknownAttempt + } + if record.state != AttemptStateCollecting { + return fmt.Errorf( + "%w: state is %v, want %v", + ErrAttemptStateInvalid, + record.state, + AttemptStateCollecting, + ) + } + if !bytes.Equal( + snapshot.AttemptContextHash, + record.handle.contextHash[:], + ) { + return ErrAttemptContextMismatch + } + + if existing, present := record.snapshots[snapshot.SenderID()]; present { + existingBytes, err := existing.SignableBytes() + if err != nil { + return fmt.Errorf("coordinator: existing signable bytes: %w", err) + } + newBytes, err := snapshot.SignableBytes() + if err != nil { + return fmt.Errorf("coordinator: new signable bytes: %w", err) + } + if !bytes.Equal(existingBytes, newBytes) || + !bytes.Equal(existing.OperatorSignature, snapshot.OperatorSignature) { + pendingEvidence = &EquivocationEvidence{ + Kind: EquivocationKindSnapshotConflict, + AttemptContextHash: append([]byte(nil), snapshot.AttemptContextHash...), + Sender: snapshot.SenderID(), + ExistingEnvelope: snapshotEnvelopeForEvidence(existing), + ConflictingEnvelope: snapshotEnvelopeForEvidence(snapshot), + } + return ErrSnapshotConflict + } + // Identical re-submission: idempotent no-op. + return nil + } + record.snapshots[snapshot.SenderID()] = snapshot + if c.selfMember != 0 && snapshot.SenderID() == c.selfMember { + record.selfSubmission = snapshot + } + return nil +} + +func (c *inMemoryCoordinator) AggregateBundle( + handle AttemptHandle, +) (*TransitionMessage, error) { + c.mu.Lock() + record, ok := c.attempts[handle.id] + if !ok { + c.mu.Unlock() + return nil, ErrUnknownAttempt + } + if c.selfMember == 0 || record.coordinator != c.selfMember { + c.mu.Unlock() + return nil, ErrNotAggregator + } + if record.state != AttemptStateCollecting { + c.mu.Unlock() + return nil, fmt.Errorf( + "%w: state is %v, want %v", + ErrAttemptStateInvalid, + record.state, + AttemptStateCollecting, + ) + } + + senders := make([]group.MemberIndex, 0, len(record.snapshots)) + for s := range record.snapshots { + senders = append(senders, s) + } + sort.Slice(senders, func(i, j int) bool { return senders[i] < senders[j] }) + + bundle := make([]LocalEvidenceSnapshot, 0, len(senders)) + for _, s := range senders { + bundle = append(bundle, *record.snapshots[s]) + } + + record.state = AttemptStateAggregating + hash := record.handle.contextHash + coord := record.coordinator + c.mu.Unlock() + + msg := &TransitionMessage{ + AttemptContextHash: append([]byte{}, hash[:]...), + CoordinatorIDValue: uint32(coord), + Bundle: bundle, + } + payload, err := msg.SignableBytes() + if err != nil { + c.markTransitionedLocked(handle.id) + return nil, fmt.Errorf("coordinator: bundle signable bytes: %w", err) + } + sig, err := c.signer.Sign(payload) + if err != nil { + c.markTransitionedLocked(handle.id) + return nil, fmt.Errorf("coordinator: sign bundle: %w", err) + } + msg.CoordinatorSignature = sig + if err := msg.Validate(); err != nil { + c.markTransitionedLocked(handle.id) + return nil, fmt.Errorf("coordinator: aggregated bundle invalid: %w", err) + } + c.markTransitionedLocked(handle.id) + return msg, nil +} + +func (c *inMemoryCoordinator) markTransitionedLocked(id uint64) { + c.mu.Lock() + defer c.mu.Unlock() + if record, ok := c.attempts[id]; ok { + record.state = AttemptStateTransitioned + } +} + +func (c *inMemoryCoordinator) MarkSucceeded(handle AttemptHandle) error { + c.mu.Lock() + defer c.mu.Unlock() + record, ok := c.attempts[handle.id] + if !ok { + return ErrUnknownAttempt + } + // Only a live attempt can succeed. A non-Collecting attempt has already + // concluded (aggregating/transitioned to failure, or already succeeded); + // re-marking it would mask a caller bug, so fail closed as the other + // state-changing methods do. + if record.state != AttemptStateCollecting { + return fmt.Errorf( + "%w: state is %v, want %v", + ErrAttemptStateInvalid, + record.state, + AttemptStateCollecting, + ) + } + record.state = AttemptStateSucceeded + return nil +} + +func (c *inMemoryCoordinator) VerifyBundle( + handle AttemptHandle, + msg *TransitionMessage, +) error { + if msg == nil { + return errors.New("coordinator: transition message is nil") + } + if err := msg.Validate(); err != nil { + return fmt.Errorf("coordinator: transition message invalid: %w", err) + } + + c.mu.Lock() + record, ok := c.attempts[handle.id] + if !ok { + c.mu.Unlock() + return ErrUnknownAttempt + } + expectedCoordinator := record.coordinator + expectedHash := record.handle.contextHash + selfSubmission := record.selfSubmission + c.mu.Unlock() + + if !bytes.Equal(msg.AttemptContextHash, expectedHash[:]) { + return ErrAttemptContextMismatch + } + if err := verifyBundleSignature(c.verifier, msg, expectedCoordinator); err != nil { + return fmt.Errorf("coordinator: %w", err) + } + if err := verifyOwnObservationsPresent(msg, c.selfMember, selfSubmission); err != nil { + return err + } + for i := range msg.Bundle { + if err := verifySnapshotSignature(c.verifier, &msg.Bundle[i]); err != nil { + return fmt.Errorf("coordinator: bundle[%d]: %w", i, err) + } + } + return nil +} diff --git a/pkg/frost/roast/coordinator_state_test.go b/pkg/frost/roast/coordinator_state_test.go new file mode 100644 index 0000000000..6960f8b486 --- /dev/null +++ b/pkg/frost/roast/coordinator_state_test.go @@ -0,0 +1,310 @@ +package roast + +import ( + "errors" + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newTestContext(t *testing.T) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "session-test", + "key-group-test", + []byte{0xab, 0xcd, 0xef}, + [attempt.MessageDigestLength]byte{0x42}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("test context: %v", err) + } + return ctx +} + +func TestBeginAttempt_ReturnsHandleWithMatchingContextHash(t *testing.T) { + coord := NewInMemoryCoordinator() + ctx := newTestContext(t) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if handle.ContextHash() != ctx.Hash() { + t.Fatalf( + "handle hash mismatch: got %x want %x", + handle.ContextHash(), ctx.Hash(), + ) + } +} + +func TestBeginAttempt_HandlesAreDistinctAcrossAttempts(t *testing.T) { + coord := NewInMemoryCoordinator() + a, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("first begin: %v", err) + } + b, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("second begin: %v", err) + } + if a.id == b.id { + t.Fatalf("two attempts shared handle id %d", a.id) + } +} + +func TestBeginAttempt_RejectsEmptyIncludedSet(t *testing.T) { + coord := NewInMemoryCoordinator() + // We bypass NewAttemptContext (which forbids empty included set) + // to assert BeginAttempt's defence-in-depth check. + ctx := attempt.AttemptContext{} + _, err := coord.BeginAttempt(ctx) + if err == nil { + t.Fatal("expected error on empty included set") + } +} + +func TestState_ReturnsCollectingAfterBegin(t *testing.T) { + coord := NewInMemoryCoordinator() + handle, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + state, err := coord.State(handle) + if err != nil { + t.Fatalf("state: %v", err) + } + if state != AttemptStateCollecting { + t.Fatalf( + "expected collecting, got %v", + state, + ) + } +} + +func TestState_UnknownHandleReturnsSentinel(t *testing.T) { + coord := NewInMemoryCoordinator() + bogus := AttemptHandle{id: 999} + state, err := coord.State(bogus) + if !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } + if state != AttemptStatePending { + t.Fatalf("expected pending sentinel, got %v", state) + } +} + +func TestSelectedCoordinator_ReturnsMemberFromIncludedSet(t *testing.T) { + coord := NewInMemoryCoordinator() + ctx := newTestContext(t) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + got, err := coord.SelectedCoordinator(handle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + found := false + for _, m := range ctx.IncludedSet { + if m == got { + found = true + break + } + } + if !found { + t.Fatalf( + "selected coordinator %d not in included set %v", + got, ctx.IncludedSet, + ) + } +} + +func TestSelectedCoordinator_IsDeterministicForSameContext(t *testing.T) { + a := NewInMemoryCoordinator() + b := NewInMemoryCoordinator() + ctx := newTestContext(t) + ha, err := a.BeginAttempt(ctx) + if err != nil { + t.Fatalf("a.begin: %v", err) + } + hb, err := b.BeginAttempt(ctx) + if err != nil { + t.Fatalf("b.begin: %v", err) + } + ca, err := a.SelectedCoordinator(ha) + if err != nil { + t.Fatalf("a.selected: %v", err) + } + cb, err := b.SelectedCoordinator(hb) + if err != nil { + t.Fatalf("b.selected: %v", err) + } + if ca != cb { + t.Fatalf( + "two coordinators disagreed on same context: %d != %d", + ca, cb, + ) + } +} + +func TestSelectedCoordinator_DifferentAttemptNumbersCanProduceDifferentLeaders(t *testing.T) { + coord := NewInMemoryCoordinator() + build := func(attemptNumber uint32) attempt.AttemptContext { + ctx, err := attempt.NewAttemptContext( + "session-test", + "key-group-test", + []byte{0x01}, + [attempt.MessageDigestLength]byte{0x42}, + attemptNumber, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("build ctx: %v", err) + } + return ctx + } + + // Sweep a few attempt numbers; verify the elected coordinator is + // not always the same member -- otherwise the retry-rotation + // property of ROAST does not hold. + seen := map[group.MemberIndex]struct{}{} + for n := uint32(0); n < 16; n++ { + ctx := build(n) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin n=%d: %v", n, err) + } + c, err := coord.SelectedCoordinator(handle) + if err != nil { + t.Fatalf("selected n=%d: %v", n, err) + } + seen[c] = struct{}{} + } + if len(seen) < 2 { + t.Fatalf( + "coordinator rotation broken: 16 different attempts all "+ + "elected the same leader; seen=%v", + seen, + ) + } +} + +func TestSelectedCoordinator_UnknownHandleReturnsSentinel(t *testing.T) { + coord := NewInMemoryCoordinator() + bogus := AttemptHandle{id: 999} + got, err := coord.SelectedCoordinator(bogus) + if !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } + if got != 0 { + t.Fatalf("expected zero member index, got %d", got) + } +} + +func TestInMemoryCoordinator_ConcurrentBeginAttemptsAreRaceSafe(t *testing.T) { + const numGoroutines = 16 + const beginsPerGoroutine = 50 + + coord := NewInMemoryCoordinator() + var wg sync.WaitGroup + handles := make(chan AttemptHandle, numGoroutines*beginsPerGoroutine) + + for g := 0; g < numGoroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < beginsPerGoroutine; i++ { + h, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Errorf("concurrent begin: %v", err) + return + } + handles <- h + } + }() + } + wg.Wait() + close(handles) + + ids := map[uint64]struct{}{} + for h := range handles { + if _, dup := ids[h.id]; dup { + t.Fatalf("duplicate handle id %d under concurrency", h.id) + } + ids[h.id] = struct{}{} + } + if len(ids) != numGoroutines*beginsPerGoroutine { + t.Fatalf( + "expected %d unique handles, got %d", + numGoroutines*beginsPerGoroutine, len(ids), + ) + } +} + +func TestAttemptState_String(t *testing.T) { + cases := map[AttemptState]string{ + AttemptStatePending: "pending", + AttemptStateCollecting: "collecting", + AttemptStateAggregating: "aggregating", + AttemptStateSucceeded: "succeeded", + AttemptStateTransitioned: "transitioned", + AttemptState(99): "unknown(99)", + } + for state, want := range cases { + if got := state.String(); got != want { + t.Errorf("State %d: got %q want %q", state, got, want) + } + } +} + +func TestMarkSucceeded_TransitionsCollectingToSucceeded(t *testing.T) { + coord := NewInMemoryCoordinator() + handle, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + + if err := coord.MarkSucceeded(handle); err != nil { + t.Fatalf("mark succeeded: %v", err) + } + + // State is now Succeeded, not Collecting - so the cleanup path's + // state == Collecting guard skips it and no spurious TransitionMessage is + // produced for an attempt that actually completed. + state, err := coord.State(handle) + if err != nil { + t.Fatalf("state: %v", err) + } + if state != AttemptStateSucceeded { + t.Fatalf("expected Succeeded, got %v", state) + } +} + +func TestMarkSucceeded_UnknownHandleReturnsSentinel(t *testing.T) { + coord := NewInMemoryCoordinator() + if err := coord.MarkSucceeded(AttemptHandle{id: 999}); !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } +} + +func TestMarkSucceeded_RejectsNonCollectingAttempt(t *testing.T) { + coord := NewInMemoryCoordinator() + handle, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + if err := coord.MarkSucceeded(handle); err != nil { + t.Fatalf("first mark succeeded: %v", err) + } + + // A second mark on an already-succeeded (non-Collecting) attempt fails + // closed rather than masking a caller bug. + if err := coord.MarkSucceeded(handle); !errors.Is(err, ErrAttemptStateInvalid) { + t.Fatalf("expected ErrAttemptStateInvalid, got %v", err) + } +} diff --git a/pkg/frost/roast/coordinator_test.go b/pkg/frost/roast/coordinator_test.go new file mode 100644 index 0000000000..f93b95f424 --- /dev/null +++ b/pkg/frost/roast/coordinator_test.go @@ -0,0 +1,172 @@ +package roast + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestSelectCoordinator_EmptySet(t *testing.T) { + _, err := SelectCoordinator([]group.MemberIndex{}, 100, 1) + if err == nil { + t.Fatal("expected coordinator selection error") + } +} + +func TestSelectCoordinator_Deterministic(t *testing.T) { + members := []group.MemberIndex{4, 1, 3, 2} + + first, err := SelectCoordinator(members, 12345, 2) + if err != nil { + t.Fatalf("selection failed: [%v]", err) + } + + for i := 0; i < 20; i++ { + again, err := SelectCoordinator(members, 12345, 2) + if err != nil { + t.Fatalf("selection failed on run [%d]: [%v]", i, err) + } + + if again != first { + t.Fatalf( + "non-deterministic coordinator\nexpected: [%v]\nactual: [%v]", + first, + again, + ) + } + } +} + +func TestSelectCoordinator_InputOrderIndependent(t *testing.T) { + left := []group.MemberIndex{1, 2, 3, 4, 5, 6} + right := []group.MemberIndex{6, 1, 5, 2, 4, 3} + + leftCoordinator, err := SelectCoordinator(left, 333, 4) + if err != nil { + t.Fatalf("left selection failed: [%v]", err) + } + + rightCoordinator, err := SelectCoordinator(right, 333, 4) + if err != nil { + t.Fatalf("right selection failed: [%v]", err) + } + + if leftCoordinator != rightCoordinator { + t.Fatalf( + "input order should not matter\nleft: [%v]\nright: [%v]", + leftCoordinator, + rightCoordinator, + ) + } +} + +func TestSelectCoordinator_AffectedByAttemptNumber(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5, 6} + first, err := SelectCoordinator(members, 777, 1) + if err != nil { + t.Fatalf("selection failed: [%v]", err) + } + + differentObserved := false + for attempt := uint(2); attempt <= 20; attempt++ { + candidate, err := SelectCoordinator(members, 777, attempt) + if err != nil { + t.Fatalf("selection failed for attempt [%d]: [%v]", attempt, err) + } + + if candidate != first { + differentObserved = true + break + } + } + + if !differentObserved { + t.Fatal("coordinator did not change for any attempt number") + } +} + +func TestSelectCoordinator_AffectedBySeed(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5, 6} + first, err := SelectCoordinator(members, 1000, 2) + if err != nil { + t.Fatalf("selection failed: [%v]", err) + } + + differentObserved := false + for seed := int64(1001); seed <= 1030; seed++ { + candidate, err := SelectCoordinator(members, seed, 2) + if err != nil { + t.Fatalf("selection failed for seed [%d]: [%v]", seed, err) + } + + if candidate != first { + differentObserved = true + break + } + } + + if !differentObserved { + t.Fatal("coordinator did not change for any seed") + } +} + +// TestSelectCoordinator_CrossLanguagePinnedVectors pins concrete +// SelectCoordinator outputs so cross-language agreement with the Rust +// engine is enforced by tests on both sides. The Rust port of Go's +// math/rand (pkg/tbtc/signer/src/go_math_rand.rs, +// select_coordinator_matches_known_keep_core_vectors) asserts these +// exact values; the FROST/ROAST liveness path depends on every honest +// node electing the same coordinator for the same attempt. +// +// If this test breaks, the Go selection semantics changed (for +// example a move to math/rand/v2, a different shuffle, or a different +// seed composition). That is a network-fracturing change: it must be +// coordinated with the Rust engine and rolled out as a new versioned +// selection rule, never shipped silently. +func TestSelectCoordinator_CrossLanguagePinnedVectors(t *testing.T) { + const seed = int64(6879463052285329321) + + vectors := []struct { + members []group.MemberIndex + seed int64 + attempt uint + coordinator group.MemberIndex + }{ + {[]group.MemberIndex{1, 2}, seed, 1, 2}, + {[]group.MemberIndex{1, 2}, seed, 2, 1}, + {[]group.MemberIndex{1, 2}, seed, 3, 2}, + {[]group.MemberIndex{1, 2, 3}, seed, 1, 3}, + {[]group.MemberIndex{1, 2, 3}, seed, 2, 2}, + {[]group.MemberIndex{1, 2, 3}, seed, 4, 1}, + {[]group.MemberIndex{1, 2, 3, 4, 5, 6}, 333, 4, 4}, + } + + for _, vector := range vectors { + actual, err := SelectCoordinator( + vector.members, + vector.seed, + vector.attempt, + ) + if err != nil { + t.Fatalf( + "selection failed for members [%v] seed [%d] attempt [%d]: [%v]", + vector.members, + vector.seed, + vector.attempt, + err, + ) + } + + if actual != vector.coordinator { + t.Fatalf( + "pinned vector drift for members [%v] seed [%d] attempt [%d]\n"+ + "expected: [%v]\nactual: [%v]", + vector.members, + vector.seed, + vector.attempt, + vector.coordinator, + actual, + ) + } + } +} diff --git a/pkg/frost/roast/domain_separation_test.go b/pkg/frost/roast/domain_separation_test.go new file mode 100644 index 0000000000..f4847469b5 --- /dev/null +++ b/pkg/frost/roast/domain_separation_test.go @@ -0,0 +1,242 @@ +package roast + +import ( + "bytes" + "errors" + "sync" + "testing" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// These pin the cross-protocol signature-confusion defense for the operator-key +// signed bodies: the signed bytes are domain-tagged and undecodable as +// protobuf, the bytes that travel on the wire stay the bare body, and the two +// body types use distinct tags so a signature over one can never verify as a +// signature over the other. + +func TestSnapshotSignableBytes_DomainSeparated(t *testing.T) { + snap := signedTestSnapshot(t, 7) + signable, err := snap.SignableBytes() + if err != nil { + t.Fatalf("signable: %v", err) + } + body, err := snap.bodyBytes() + if err != nil { + t.Fatalf("body: %v", err) + } + + // SignableBytes = snapshot domain tag || bare body. + if !bytes.HasPrefix(signable, localEvidenceSnapshotSignatureDomain) { + t.Fatal("snapshot signed bytes must carry the snapshot domain tag") + } + if !bytes.Equal(signable[len(localEvidenceSnapshotSignatureDomain):], body) { + t.Fatal("snapshot signed bytes must be the domain tag followed by the bare body") + } + + // The signed bytes begin with an illegal protobuf tag (field 0) and so are + // undecodable as any protobuf message - a snapshot signature can never be + // accepted on a transition (or other) envelope whose decoder parses the + // forged body. + if signable[0] != 0x00 { + t.Fatal("snapshot signed bytes must begin with an illegal protobuf tag (0x00)") + } + if err := proto.Unmarshal(signable, &pb.LocalEvidenceSnapshotBody{}); err == nil { + t.Fatal("snapshot signed bytes must not decode as a protobuf message") + } + + // The bare wire body, by contrast, carries no tag and IS a valid protobuf + // body - the domain tag never travels on the wire. + if bytes.HasPrefix(body, localEvidenceSnapshotSignatureDomain) { + t.Fatal("the wire body must not carry the domain tag") + } + if err := proto.Unmarshal(body, &pb.LocalEvidenceSnapshotBody{}); err != nil { + t.Fatalf("the bare wire body must be a valid protobuf body: %v", err) + } +} + +func TestTransitionSignableBytes_DomainSeparated(t *testing.T) { + msg := buildValidTransitionMessage() + signable, err := msg.SignableBytes() + if err != nil { + t.Fatalf("signable: %v", err) + } + body, err := msg.bodyBytes() + if err != nil { + t.Fatalf("body: %v", err) + } + + if !bytes.HasPrefix(signable, transitionMessageSignatureDomain) { + t.Fatal("transition signed bytes must carry the transition domain tag") + } + if !bytes.Equal(signable[len(transitionMessageSignatureDomain):], body) { + t.Fatal("transition signed bytes must be the domain tag followed by the bare body") + } + if signable[0] != 0x00 { + t.Fatal("transition signed bytes must begin with an illegal protobuf tag (0x00)") + } + if err := proto.Unmarshal(signable, &pb.TransitionMessageBody{}); err == nil { + t.Fatal("transition signed bytes must not decode as a protobuf message") + } + if bytes.HasPrefix(body, transitionMessageSignatureDomain) { + t.Fatal("the wire body must not carry the domain tag") + } + if err := proto.Unmarshal(body, &pb.TransitionMessageBody{}); err != nil { + t.Fatalf("the bare wire body must be a valid protobuf body: %v", err) + } +} + +func TestSignedBodyDomains_AreDistinctAndPrefixFree(t *testing.T) { + // Distinct, prefix-free tags make the signed-byte spaces of the two body + // types disjoint: domain_a || body_a == domain_b || body_b is impossible + // unless one tag is a prefix of the other, so a signature over one body can + // never verify as a signature over the other. + a := localEvidenceSnapshotSignatureDomain + b := transitionMessageSignatureDomain + if bytes.Equal(a, b) { + t.Fatal("each signed body type must use a distinct domain tag") + } + if bytes.HasPrefix(a, b) || bytes.HasPrefix(b, a) { + t.Fatal("no domain tag may be a prefix of another") + } + for _, tag := range [][]byte{a, b} { + if len(tag) == 0 || tag[0] != 0x00 { + t.Fatalf("domain tag %q must begin with an illegal protobuf tag (0x00)", tag) + } + } +} + +func TestSnapshotUnmarshal_ResetsSignableCache(t *testing.T) { + // A snapshot value reused across a SignableBytes call and then an Unmarshal + // must authenticate the newly decoded snapshot against the bytes it just + // received, never the stale cached payload. + reused := signedTestSnapshot(t, 7) + if _, err := reused.SignableBytes(); err != nil { // prime the cache + t.Fatalf("prime cache: %v", err) + } + + genuine := signedTestSnapshot(t, 9) + wire, err := genuine.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := reused.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal into reused value: %v", err) + } + + got, _ := reused.SignableBytes() + want, _ := genuine.SignableBytes() + if !bytes.Equal(got, want) { + t.Fatal("Unmarshal must reset the snapshot signable-bytes cache") + } + if err := verifySnapshotSignature(fakeVerifier{}, reused); err != nil { + t.Fatalf("authenticate reused-decoded snapshot: %v", err) + } +} + +func TestTransitionUnmarshal_ResetsSignableCache(t *testing.T) { + reused := buildValidTransitionMessage() + if _, err := reused.SignableBytes(); err != nil { // prime the cache + t.Fatalf("prime cache: %v", err) + } + + // Decode a structurally different genuine bundle into the SAME value. + other := buildValidTransitionMessage() + other.CoordinatorIDValue = 2 + other.CoordinatorSignature = bytes.Repeat([]byte{0xcd}, 64) + wire, err := other.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := reused.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal into reused value: %v", err) + } + + got, _ := reused.SignableBytes() + want, _ := other.SignableBytes() + if !bytes.Equal(got, want) { + t.Fatal("Unmarshal must reset the transition signable-bytes cache") + } +} + +func TestCrossProtocol_TransitionSignatureRejectedAsSnapshot(t *testing.T) { + // End-to-end: a coordinator signature over a transition message must not + // verify as an operator signature over a snapshot, even when the snapshot + // shares the signer id and attempt context. The distinct domain tags (and + // bodies) make the signed preimages disjoint. + const id group.MemberIndex = 7 + + transition := buildValidTransitionMessage() + transition.CoordinatorIDValue = uint32(id) + transition.CoordinatorSignature = nil + tPayload, err := transition.SignableBytes() + if err != nil { + t.Fatalf("transition signable: %v", err) + } + tSig, err := (&fakeSigner{id: id}).Sign(tPayload) + if err != nil { + t.Fatalf("sign transition: %v", err) + } + transition.CoordinatorSignature = tSig + // Control: the signature really is a valid bundle signature. + if err := verifyBundleSignature(fakeVerifier{}, transition, id); err != nil { + t.Fatalf("control: genuine transition signature must verify: %v", err) + } + + // Paste it onto a snapshot from the same signer + attempt. + snap := NewLocalEvidenceSnapshot(id, pinnedContextHash, attempt.Evidence{}) + snap.OperatorSignature = tSig + if err := verifySnapshotSignature(fakeVerifier{}, snap); !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("transition signature must not verify as a snapshot signature; got %v", err) + } +} + +func TestCrossProtocol_SnapshotSignatureRejectedAsTransition(t *testing.T) { + // The reverse direction: an operator signature over a snapshot must not + // verify as a coordinator signature over a transition message. + const id group.MemberIndex = 7 + snap := signedTestSnapshot(t, id) + // Control: it verifies as a snapshot signature. + if err := verifySnapshotSignature(fakeVerifier{}, snap); err != nil { + t.Fatalf("control: genuine snapshot signature must verify: %v", err) + } + + transition := buildValidTransitionMessage() + transition.CoordinatorIDValue = uint32(id) + transition.CoordinatorSignature = snap.OperatorSignature + if err := verifyBundleSignature(fakeVerifier{}, transition, id); !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("snapshot signature must not verify as a transition signature; got %v", err) + } +} + +func TestSnapshotSignableBytes_ConcurrentAfterUnmarshalIsRaceFree(t *testing.T) { + // Regression guard (run under -race): a parsed snapshot must carry a primed + // signable-bytes cache so concurrent signature verification reads a ready + // cache instead of racing on lazy initialization. Without priming in + // Unmarshal, the concurrent first SignableBytes calls below race on the + // cache write. + wire, err := signedTestSnapshot(t, 7).Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded LocalEvidenceSnapshot + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := decoded.SignableBytes(); err != nil { + t.Errorf("signable: %v", err) + } + }() + } + wg.Wait() +} diff --git a/pkg/frost/roast/equivocation.go b/pkg/frost/roast/equivocation.go new file mode 100644 index 0000000000..0677dec8b1 --- /dev/null +++ b/pkg/frost/roast/equivocation.go @@ -0,0 +1,146 @@ +package roast + +import ( + "encoding/hex" + "fmt" + "sync" + + "github.com/ipfs/go-log/v2" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +var equivocationLogger = log.Logger("keep-frost-roast-equivocation") + +// Equivocation evidence kinds. Each event carries the exact signed +// envelope bytes behind a detection, so telemetry retains enough data to +// diagnose whether targeted equivocation is occurring before the full +// proof-carrying-blame wire format (follow-up item 7) exists. Two +// operator-signed bodies from the same sender for the same attempt are +// self-incriminating: both signatures verify, the bodies differ. +const ( + // EquivocationKindSnapshotConflict: a sender re-submitted a signed + // snapshot for the same attempt that differs from its first + // submission (first-write-wins conflict at the coordinator). + EquivocationKindSnapshotConflict = "snapshot_conflict" + // EquivocationKindOwnSnapshotMutatedInBundle: a coordinator bundle + // carries this member's snapshot with a different signature than the + // member actually submitted. + EquivocationKindOwnSnapshotMutatedInBundle = "own_snapshot_mutated_in_bundle" + // EquivocationKindOwnSnapshotMissingFromBundle: a coordinator bundle + // omits this member's submitted snapshot entirely. + EquivocationKindOwnSnapshotMissingFromBundle = "own_snapshot_missing_from_bundle" +) + +// EquivocationEvidence carries the exact signed byte streams behind a +// detected conflict, censorship, or mutation event. Envelope fields hold +// SignedLocalEvidenceSnapshot wire bytes verbatim (body + operator +// signature) and may be nil when the corresponding side could not be +// encoded or does not exist (e.g. a snapshot missing from a bundle). +type EquivocationEvidence struct { + Kind string + AttemptContextHash []byte + Sender group.MemberIndex + // ExistingEnvelope is the first-accepted / self-submitted signed + // snapshot envelope. + ExistingEnvelope []byte + // ConflictingEnvelope is the re-submitted / bundled signed snapshot + // envelope that disagrees with ExistingEnvelope. + ConflictingEnvelope []byte +} + +// EquivocationEvidenceObserver consumes equivocation evidence events. +type EquivocationEvidenceObserver func(evidence EquivocationEvidence) + +var ( + equivocationEvidenceObserverMutex sync.RWMutex + equivocationEvidenceObserver EquivocationEvidenceObserver +) + +// RegisterEquivocationEvidenceObserver registers a process-wide observer +// used to retain equivocation evidence in the host's telemetry system. +// Only a single observer is supported. +func RegisterEquivocationEvidenceObserver( + observer EquivocationEvidenceObserver, +) error { + if observer == nil { + return fmt.Errorf("equivocation evidence observer is nil") + } + + equivocationEvidenceObserverMutex.Lock() + defer equivocationEvidenceObserverMutex.Unlock() + + if equivocationEvidenceObserver != nil { + return fmt.Errorf("equivocation evidence observer is already registered") + } + + equivocationEvidenceObserver = observer + + return nil +} + +// UnregisterEquivocationEvidenceObserver clears the observer registration. +func UnregisterEquivocationEvidenceObserver() { + equivocationEvidenceObserverMutex.Lock() + defer equivocationEvidenceObserverMutex.Unlock() + + equivocationEvidenceObserver = nil +} + +// emitEquivocationEvidence logs the full evidence (these events are rare +// and the bytes are the diagnosis) and forwards it to the registered +// observer, if any. Never fails: evidence retention must not perturb the +// protocol path that detected the event. +func emitEquivocationEvidence(evidence EquivocationEvidence) { + equivocationLogger.Warnf( + "equivocation evidence [%s]: sender [%d], attempt context hash [%s], "+ + "existing envelope [%s], conflicting envelope [%s]", + evidence.Kind, + evidence.Sender, + hex.EncodeToString(evidence.AttemptContextHash), + hex.EncodeToString(evidence.ExistingEnvelope), + hex.EncodeToString(evidence.ConflictingEnvelope), + ) + + equivocationEvidenceObserverMutex.RLock() + observer := equivocationEvidenceObserver + equivocationEvidenceObserverMutex.RUnlock() + + if observer != nil { + // Honor the never-fails contract: a panicking telemetry observer + // must not escape into the protocol path that detected the event. + func() { + defer func() { + if r := recover(); r != nil { + equivocationLogger.Errorf( + "equivocation evidence observer panicked: [%v]", r, + ) + } + }() + observer(evidence) + }() + } +} + +// snapshotEnvelopeForEvidence encodes a snapshot's signed envelope for +// evidence retention, tolerating encode failures (nil result) so the +// detection path never degrades. +func snapshotEnvelopeForEvidence(snapshot *LocalEvidenceSnapshot) []byte { + if snapshot == nil { + return nil + } + envelope, err := snapshot.Marshal() + if err != nil { + equivocationLogger.Warnf( + "could not encode snapshot envelope for evidence retention: [%v]", + err, + ) + return nil + } + // Defensive copy: Marshal returns the snapshot's internal wire-envelope + // cache (its contract forbids callers from mutating it), but evidence + // bytes are handed to an external observer that may retain and later + // normalize, zero, or otherwise mutate them - which would corrupt the + // cached signed bytes on the stored snapshot. + return append([]byte(nil), envelope...) +} diff --git a/pkg/frost/roast/equivocation_test.go b/pkg/frost/roast/equivocation_test.go new file mode 100644 index 0000000000..03de8c4972 --- /dev/null +++ b/pkg/frost/roast/equivocation_test.go @@ -0,0 +1,223 @@ +package roast + +import ( + "bytes" + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func captureEquivocationEvidence(t *testing.T) *[]EquivocationEvidence { + t.Helper() + captured := &[]EquivocationEvidence{} + if err := RegisterEquivocationEvidenceObserver( + func(evidence EquivocationEvidence) { + *captured = append(*captured, evidence) + }, + ); err != nil { + t.Fatalf("register observer: %v", err) + } + t.Cleanup(UnregisterEquivocationEvidenceObserver) + return captured +} + +func TestSnapshotConflict_RetainsBothSignedEnvelopes(t *testing.T) { + captured := captureEquivocationEvidence(t) + + c := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + + first := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(3, ctx.Hash(), attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 1}, + }), + ) + if err := c.RecordEvidence(handle, first); err != nil { + t.Fatalf("record first: %v", err) + } + + // The same sender equivocates: a different signed snapshot for the + // same attempt. + conflicting := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(3, ctx.Hash(), attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 2}, + }), + ) + if err := c.RecordEvidence(handle, conflicting); !errors.Is(err, ErrSnapshotConflict) { + t.Fatalf("expected ErrSnapshotConflict, got %v", err) + } + + if len(*captured) != 1 { + t.Fatalf("expected 1 evidence event, got %d", len(*captured)) + } + evidence := (*captured)[0] + if evidence.Kind != EquivocationKindSnapshotConflict { + t.Fatalf("kind = %q", evidence.Kind) + } + if evidence.Sender != 3 { + t.Fatalf("sender = %d", evidence.Sender) + } + wantExisting, _ := first.Marshal() + wantConflicting, _ := conflicting.Marshal() + if !bytes.Equal(evidence.ExistingEnvelope, wantExisting) { + t.Fatal("existing envelope bytes must match the first submission verbatim") + } + if !bytes.Equal(evidence.ConflictingEnvelope, wantConflicting) { + t.Fatal("conflicting envelope bytes must match the re-submission verbatim") + } + + // Idempotent identical re-submission must NOT emit evidence. + if err := c.RecordEvidence(handle, first); err != nil { + t.Fatalf("identical re-submission should be a no-op: %v", err) + } + if len(*captured) != 1 { + t.Fatalf("identical re-submission emitted evidence: %d events", len(*captured)) + } +} + +func TestOwnSnapshotMutatedInBundle_RetainsBothSignedEnvelopes(t *testing.T) { + captured := captureEquivocationEvidence(t) + + selfSubmission := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}), + ) + + mutated := *selfSubmission + mutated.OperatorSignature = bytes.Repeat([]byte{0xff}, 64) + // Fresh caches: the mutated copy is a distinct signed object. + mutated.bodyCache = nil + mutated.signaturePayloadCache = nil + mutated.wireEnvelope = nil + + bundle := &TransitionMessage{ + AttemptContextHash: append([]byte{}, pinnedContextHash[:]...), + Bundle: []LocalEvidenceSnapshot{mutated}, + } + if err := verifyOwnObservationsPresent(bundle, 7, selfSubmission); !errors.Is(err, ErrCensorshipDetected) { + t.Fatalf("expected ErrCensorshipDetected, got %v", err) + } + + if len(*captured) != 1 { + t.Fatalf("expected 1 evidence event, got %d", len(*captured)) + } + evidence := (*captured)[0] + if evidence.Kind != EquivocationKindOwnSnapshotMutatedInBundle { + t.Fatalf("kind = %q", evidence.Kind) + } + wantSelf, _ := selfSubmission.Marshal() + if !bytes.Equal(evidence.ExistingEnvelope, wantSelf) { + t.Fatal("existing envelope must be the self submission verbatim") + } + if len(evidence.ConflictingEnvelope) == 0 { + t.Fatal("conflicting envelope must carry the bundled snapshot") + } +} + +func TestOwnSnapshotMissingFromBundle_RetainsSelfEnvelope(t *testing.T) { + captured := captureEquivocationEvidence(t) + + selfSubmission := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}), + ) + bundle := &TransitionMessage{ + AttemptContextHash: append([]byte{}, pinnedContextHash[:]...), + Bundle: []LocalEvidenceSnapshot{}, + } + if err := verifyOwnObservationsPresent(bundle, 7, selfSubmission); !errors.Is(err, ErrCensorshipDetected) { + t.Fatalf("expected ErrCensorshipDetected, got %v", err) + } + + if len(*captured) != 1 { + t.Fatalf("expected 1 evidence event, got %d", len(*captured)) + } + evidence := (*captured)[0] + if evidence.Kind != EquivocationKindOwnSnapshotMissingFromBundle { + t.Fatalf("kind = %q", evidence.Kind) + } + wantSelf, _ := selfSubmission.Marshal() + if !bytes.Equal(evidence.ExistingEnvelope, wantSelf) { + t.Fatal("existing envelope must be the self submission verbatim") + } + if evidence.ConflictingEnvelope != nil { + t.Fatal("missing-snapshot evidence has no conflicting envelope") + } +} + +func TestEquivocationObserver_PanicDoesNotEscapeProtocolPath(t *testing.T) { + if err := RegisterEquivocationEvidenceObserver( + func(_ EquivocationEvidence) { panic("observer boom") }, + ); err != nil { + t.Fatalf("register observer: %v", err) + } + t.Cleanup(UnregisterEquivocationEvidenceObserver) + + c := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + first := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(3, ctx.Hash(), attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 1}, + }), + ) + if err := c.RecordEvidence(handle, first); err != nil { + t.Fatalf("record first: %v", err) + } + conflicting := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(3, ctx.Hash(), attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 2}, + }), + ) + // A panicking observer must not crash the protocol path: the intended + // ErrSnapshotConflict must still surface. + if err := c.RecordEvidence(handle, conflicting); !errors.Is(err, ErrSnapshotConflict) { + t.Fatalf("expected ErrSnapshotConflict despite panicking observer, got %v", err) + } +} + +func TestEquivocationEvidence_ObserverCannotCorruptSnapshotCache(t *testing.T) { + snapshot := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 1}, + }), + ) + pristine, err := snapshot.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + pristineCopy := append([]byte(nil), pristine...) + + // Evidence bytes must not alias the snapshot's internal cache: mutating + // the returned evidence envelope must leave the snapshot's signed bytes + // intact. + evidenceBytes := snapshotEnvelopeForEvidence(snapshot) + if len(evidenceBytes) == 0 { + t.Fatal("expected evidence envelope bytes") + } + for i := range evidenceBytes { + evidenceBytes[i] ^= 0xff + } + + after, err := snapshot.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(after, pristineCopy) { + t.Fatal("mutating evidence bytes corrupted the snapshot's cached envelope") + } +} diff --git a/pkg/frost/roast/gen/pb/evidence.pb.go b/pkg/frost/roast/gen/pb/evidence.pb.go new file mode 100644 index 0000000000..858eb898dc --- /dev/null +++ b/pkg/frost/roast/gen/pb/evidence.pb.go @@ -0,0 +1,585 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.3 +// protoc v6.33.4 +// source: pkg/frost/roast/gen/pb/evidence.proto + +package pb + +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) +) + +// The evidence-snapshot body. Carried verbatim as +// SignedLocalEvidenceSnapshot.body; the operator signature covers the +// domain-tagged form of these bytes (domain_tag || body), not the bare bytes. +type LocalEvidenceSnapshotBody struct { + state protoimpl.MessageState `protogen:"open.v1"` + SenderId uint32 `protobuf:"varint,1,opt,name=sender_id,json=senderId,proto3" json:"sender_id,omitempty"` + // 32-byte attempt context hash binding the evidence to one attempt. + AttemptContextHash []byte `protobuf:"bytes,2,opt,name=attempt_context_hash,json=attemptContextHash,proto3" json:"attempt_context_hash,omitempty"` + // Sorted ascending by sender; producers emit the canonical order, and + // receivers validate it after signature verification. + Overflows []*OverflowEntry `protobuf:"bytes,3,rep,name=overflows,proto3" json:"overflows,omitempty"` + // Sorted ascending by (sender, reason). + Rejects []*RejectEntry `protobuf:"bytes,4,rep,name=rejects,proto3" json:"rejects,omitempty"` + // Sorted ascending by sender. + Conflicts []*ConflictEntry `protobuf:"bytes,5,rep,name=conflicts,proto3" json:"conflicts,omitempty"` + // coordinator_package_proofs carries the coordinator-signed signing-package + // envelope(s) this observer accepted for the attempt - the authoritative one, + // plus a second body-different one if the coordinator equivocated to this + // observer. At most two; sorted ascending bytewise for a canonical signed body. + // Each entry is a serialized SignedSigningPackage and travels verbatim like the + // other evidence bytes. NextAttempt verifies each (coordinator signature + + // attempt binding), dedupes by signing-package body hash, and excludes a + // coordinator that signed >= 2 distinct bodies for one attempt. + CoordinatorPackageProofs [][]byte `protobuf:"bytes,6,rep,name=coordinator_package_proofs,json=coordinatorPackageProofs,proto3" json:"coordinator_package_proofs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalEvidenceSnapshotBody) Reset() { + *x = LocalEvidenceSnapshotBody{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalEvidenceSnapshotBody) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalEvidenceSnapshotBody) ProtoMessage() {} + +func (x *LocalEvidenceSnapshotBody) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalEvidenceSnapshotBody.ProtoReflect.Descriptor instead. +func (*LocalEvidenceSnapshotBody) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{0} +} + +func (x *LocalEvidenceSnapshotBody) GetSenderId() uint32 { + if x != nil { + return x.SenderId + } + return 0 +} + +func (x *LocalEvidenceSnapshotBody) GetAttemptContextHash() []byte { + if x != nil { + return x.AttemptContextHash + } + return nil +} + +func (x *LocalEvidenceSnapshotBody) GetOverflows() []*OverflowEntry { + if x != nil { + return x.Overflows + } + return nil +} + +func (x *LocalEvidenceSnapshotBody) GetRejects() []*RejectEntry { + if x != nil { + return x.Rejects + } + return nil +} + +func (x *LocalEvidenceSnapshotBody) GetConflicts() []*ConflictEntry { + if x != nil { + return x.Conflicts + } + return nil +} + +func (x *LocalEvidenceSnapshotBody) GetCoordinatorPackageProofs() [][]byte { + if x != nil { + return x.CoordinatorPackageProofs + } + return nil +} + +type OverflowEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sender uint32 `protobuf:"varint,1,opt,name=sender,proto3" json:"sender,omitempty"` + Count uint64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OverflowEntry) Reset() { + *x = OverflowEntry{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OverflowEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OverflowEntry) ProtoMessage() {} + +func (x *OverflowEntry) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OverflowEntry.ProtoReflect.Descriptor instead. +func (*OverflowEntry) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{1} +} + +func (x *OverflowEntry) GetSender() uint32 { + if x != nil { + return x.Sender + } + return 0 +} + +func (x *OverflowEntry) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +type RejectEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sender uint32 `protobuf:"varint,1,opt,name=sender,proto3" json:"sender,omitempty"` + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + Count uint64 `protobuf:"varint,3,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RejectEntry) Reset() { + *x = RejectEntry{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RejectEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RejectEntry) ProtoMessage() {} + +func (x *RejectEntry) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RejectEntry.ProtoReflect.Descriptor instead. +func (*RejectEntry) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{2} +} + +func (x *RejectEntry) GetSender() uint32 { + if x != nil { + return x.Sender + } + return 0 +} + +func (x *RejectEntry) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *RejectEntry) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +type ConflictEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sender uint32 `protobuf:"varint,1,opt,name=sender,proto3" json:"sender,omitempty"` + Count uint64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConflictEntry) Reset() { + *x = ConflictEntry{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConflictEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConflictEntry) ProtoMessage() {} + +func (x *ConflictEntry) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConflictEntry.ProtoReflect.Descriptor instead. +func (*ConflictEntry) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{3} +} + +func (x *ConflictEntry) GetSender() uint32 { + if x != nil { + return x.Sender + } + return 0 +} + +func (x *ConflictEntry) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +// The on-wire snapshot message: the exact serialized LocalEvidenceSnapshotBody +// bytes plus the operator signature, which covers the domain-tagged body +// (domain_tag || body), not the bare body field. +type SignedLocalEvidenceSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body []byte `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + OperatorSignature []byte `protobuf:"bytes,2,opt,name=operator_signature,json=operatorSignature,proto3" json:"operator_signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignedLocalEvidenceSnapshot) Reset() { + *x = SignedLocalEvidenceSnapshot{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignedLocalEvidenceSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignedLocalEvidenceSnapshot) ProtoMessage() {} + +func (x *SignedLocalEvidenceSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SignedLocalEvidenceSnapshot.ProtoReflect.Descriptor instead. +func (*SignedLocalEvidenceSnapshot) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{4} +} + +func (x *SignedLocalEvidenceSnapshot) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *SignedLocalEvidenceSnapshot) GetOperatorSignature() []byte { + if x != nil { + return x.OperatorSignature + } + return nil +} + +// The transition-message body. signed_snapshots carries each member's +// SignedLocalEvidenceSnapshot envelope verbatim as received, so the +// coordinator attests to the exact signed snapshots it assembled, in order, +// and downstream verifiers re-check the operator signatures over those same +// exact bytes. Carried verbatim as SignedTransitionMessage.body; the +// coordinator signature covers the domain-tagged form of these bytes +// (domain_tag || body), not the bare bytes. +type TransitionMessageBody struct { + state protoimpl.MessageState `protogen:"open.v1"` + AttemptContextHash []byte `protobuf:"bytes,1,opt,name=attempt_context_hash,json=attemptContextHash,proto3" json:"attempt_context_hash,omitempty"` + CoordinatorId uint32 `protobuf:"varint,2,opt,name=coordinator_id,json=coordinatorId,proto3" json:"coordinator_id,omitempty"` + SignedSnapshots [][]byte `protobuf:"bytes,3,rep,name=signed_snapshots,json=signedSnapshots,proto3" json:"signed_snapshots,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransitionMessageBody) Reset() { + *x = TransitionMessageBody{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransitionMessageBody) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransitionMessageBody) ProtoMessage() {} + +func (x *TransitionMessageBody) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransitionMessageBody.ProtoReflect.Descriptor instead. +func (*TransitionMessageBody) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{5} +} + +func (x *TransitionMessageBody) GetAttemptContextHash() []byte { + if x != nil { + return x.AttemptContextHash + } + return nil +} + +func (x *TransitionMessageBody) GetCoordinatorId() uint32 { + if x != nil { + return x.CoordinatorId + } + return 0 +} + +func (x *TransitionMessageBody) GetSignedSnapshots() [][]byte { + if x != nil { + return x.SignedSnapshots + } + return nil +} + +// The on-wire transition message: the exact serialized TransitionMessageBody +// bytes plus the coordinator signature, which covers the domain-tagged body +// (domain_tag || body), not the bare body field. +type SignedTransitionMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body []byte `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + CoordinatorSignature []byte `protobuf:"bytes,2,opt,name=coordinator_signature,json=coordinatorSignature,proto3" json:"coordinator_signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignedTransitionMessage) Reset() { + *x = SignedTransitionMessage{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignedTransitionMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignedTransitionMessage) ProtoMessage() {} + +func (x *SignedTransitionMessage) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SignedTransitionMessage.ProtoReflect.Descriptor instead. +func (*SignedTransitionMessage) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{6} +} + +func (x *SignedTransitionMessage) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *SignedTransitionMessage) GetCoordinatorSignature() []byte { + if x != nil { + return x.CoordinatorSignature + } + return nil +} + +var File_pkg_frost_roast_gen_pb_evidence_proto protoreflect.FileDescriptor + +var file_pkg_frost_roast_gen_pb_evidence_proto_rawDesc = []byte{ + 0x0a, 0x25, 0x70, 0x6b, 0x67, 0x2f, 0x66, 0x72, 0x6f, 0x73, 0x74, 0x2f, 0x72, 0x6f, 0x61, 0x73, + 0x74, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x62, 0x2f, 0x65, 0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x22, 0xbe, + 0x02, 0x0a, 0x19, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x45, 0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, 0x65, + 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1b, 0x0a, 0x09, + 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x08, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, + 0x65, 0x6d, 0x70, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x68, 0x61, 0x73, + 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x12, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x32, 0x0a, 0x09, 0x6f, + 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x2e, 0x4f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x6f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x73, 0x12, + 0x2c, 0x0a, 0x07, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x12, 0x2e, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x32, 0x0a, + 0x09, 0x63, 0x6f, 0x6e, 0x66, 0x6c, 0x69, 0x63, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x14, 0x2e, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x6c, 0x69, 0x63, + 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x66, 0x6c, 0x69, 0x63, 0x74, + 0x73, 0x12, 0x3c, 0x0a, 0x1a, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, + 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x5f, 0x70, 0x72, 0x6f, 0x6f, 0x66, 0x73, 0x18, + 0x06, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x18, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, + 0x6f, 0x72, 0x50, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x73, 0x22, + 0x3d, 0x0a, 0x0d, 0x4f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x53, + 0x0a, 0x0b, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x16, 0x0a, + 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x73, + 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x14, 0x0a, + 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x22, 0x3d, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x66, 0x6c, 0x69, 0x63, 0x74, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x22, 0x60, 0x0a, 0x1b, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x4c, 0x6f, 0x63, 0x61, + 0x6c, 0x45, 0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x2d, 0x0a, 0x12, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, + 0x72, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x11, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x69, 0x67, 0x6e, 0x61, + 0x74, 0x75, 0x72, 0x65, 0x22, 0x9b, 0x01, 0x0a, 0x15, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x30, + 0x0a, 0x14, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, + 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x12, 0x61, 0x74, + 0x74, 0x65, 0x6d, 0x70, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x48, 0x61, 0x73, 0x68, + 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, + 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x69, 0x67, 0x6e, 0x65, + 0x64, 0x5f, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, + 0x0c, 0x52, 0x0f, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, + 0x74, 0x73, 0x22, 0x62, 0x0a, 0x17, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x54, 0x72, 0x61, 0x6e, + 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, + 0x79, 0x12, 0x33, 0x0a, 0x15, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, + 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x14, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x69, 0x67, + 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_pkg_frost_roast_gen_pb_evidence_proto_rawDescOnce sync.Once + file_pkg_frost_roast_gen_pb_evidence_proto_rawDescData = file_pkg_frost_roast_gen_pb_evidence_proto_rawDesc +) + +func file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP() []byte { + file_pkg_frost_roast_gen_pb_evidence_proto_rawDescOnce.Do(func() { + file_pkg_frost_roast_gen_pb_evidence_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_frost_roast_gen_pb_evidence_proto_rawDescData) + }) + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescData +} + +var file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_pkg_frost_roast_gen_pb_evidence_proto_goTypes = []any{ + (*LocalEvidenceSnapshotBody)(nil), // 0: roast.LocalEvidenceSnapshotBody + (*OverflowEntry)(nil), // 1: roast.OverflowEntry + (*RejectEntry)(nil), // 2: roast.RejectEntry + (*ConflictEntry)(nil), // 3: roast.ConflictEntry + (*SignedLocalEvidenceSnapshot)(nil), // 4: roast.SignedLocalEvidenceSnapshot + (*TransitionMessageBody)(nil), // 5: roast.TransitionMessageBody + (*SignedTransitionMessage)(nil), // 6: roast.SignedTransitionMessage +} +var file_pkg_frost_roast_gen_pb_evidence_proto_depIdxs = []int32{ + 1, // 0: roast.LocalEvidenceSnapshotBody.overflows:type_name -> roast.OverflowEntry + 2, // 1: roast.LocalEvidenceSnapshotBody.rejects:type_name -> roast.RejectEntry + 3, // 2: roast.LocalEvidenceSnapshotBody.conflicts:type_name -> roast.ConflictEntry + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_pkg_frost_roast_gen_pb_evidence_proto_init() } +func file_pkg_frost_roast_gen_pb_evidence_proto_init() { + if File_pkg_frost_roast_gen_pb_evidence_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_pkg_frost_roast_gen_pb_evidence_proto_rawDesc, + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pkg_frost_roast_gen_pb_evidence_proto_goTypes, + DependencyIndexes: file_pkg_frost_roast_gen_pb_evidence_proto_depIdxs, + MessageInfos: file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes, + }.Build() + File_pkg_frost_roast_gen_pb_evidence_proto = out.File + file_pkg_frost_roast_gen_pb_evidence_proto_rawDesc = nil + file_pkg_frost_roast_gen_pb_evidence_proto_goTypes = nil + file_pkg_frost_roast_gen_pb_evidence_proto_depIdxs = nil +} diff --git a/pkg/frost/roast/gen/pb/evidence.proto b/pkg/frost/roast/gen/pb/evidence.proto new file mode 100644 index 0000000000..70d23b5b22 --- /dev/null +++ b/pkg/frost/roast/gen/pb/evidence.proto @@ -0,0 +1,95 @@ +syntax = "proto3"; + +option go_package = "./pb"; +package roast; + +// Evidence wire format (RFC-21 Layer B). +// +// The body bytes travel verbatim: a verifier checks the signature over the +// bytes it received and only then parses them. Nothing in the evidence chain +// is ever re-encoded, so signature validity never depends on any serializer's +// canonical form - across protobuf library versions or across languages (the +// Phase 7 Rust signer verifies and parses these same bytes). +// +// SIGNED PAYLOAD (cross-language contract). Operator and coordinator +// signatures do NOT cover the bare body; they cover a domain-separated byte +// stream, domain_tag || serialized body, where domain_tag is, in order: a +// single 0x00 byte, a fixed ASCII string, and a trailing 0x00 byte. The +// leading 0x00 is an illegal protobuf tag (field number 0), so the signed +// payload is undecodable as any protobuf message and a signature over one body +// type can never be accepted for another. The tag is NOT carried on the wire - +// only the body is, in the .body field. The ASCII strings are +// "roast/signed-evidence-snapshot/v1" (operator_signature) and +// "roast/signed-transition-message/v1" (coordinator_signature). Any +// implementation that signs or verifies the bare body (without the exact tag) +// will fail to interoperate. + +// The evidence-snapshot body. Carried verbatim as +// SignedLocalEvidenceSnapshot.body; the operator signature covers the +// domain-tagged form of these bytes (domain_tag || body), not the bare bytes. +message LocalEvidenceSnapshotBody { + uint32 sender_id = 1; + // 32-byte attempt context hash binding the evidence to one attempt. + bytes attempt_context_hash = 2; + // Sorted ascending by sender; producers emit the canonical order, and + // receivers validate it after signature verification. + repeated OverflowEntry overflows = 3; + // Sorted ascending by (sender, reason). + repeated RejectEntry rejects = 4; + // Sorted ascending by sender. + repeated ConflictEntry conflicts = 5; + // coordinator_package_proofs carries the coordinator-signed signing-package + // envelope(s) this observer accepted for the attempt - the authoritative one, + // plus a second body-different one if the coordinator equivocated to this + // observer. At most two; sorted ascending bytewise for a canonical signed body. + // Each entry is a serialized SignedSigningPackage and travels verbatim like the + // other evidence bytes. NextAttempt verifies each (coordinator signature + + // attempt binding), dedupes by signing-package body hash, and excludes a + // coordinator that signed >= 2 distinct bodies for one attempt. + repeated bytes coordinator_package_proofs = 6; +} + +message OverflowEntry { + uint32 sender = 1; + uint64 count = 2; +} + +message RejectEntry { + uint32 sender = 1; + string reason = 2; + uint64 count = 3; +} + +message ConflictEntry { + uint32 sender = 1; + uint64 count = 2; +} + +// The on-wire snapshot message: the exact serialized LocalEvidenceSnapshotBody +// bytes plus the operator signature, which covers the domain-tagged body +// (domain_tag || body), not the bare body field. +message SignedLocalEvidenceSnapshot { + bytes body = 1; + bytes operator_signature = 2; +} + +// The transition-message body. signed_snapshots carries each member's +// SignedLocalEvidenceSnapshot envelope verbatim as received, so the +// coordinator attests to the exact signed snapshots it assembled, in order, +// and downstream verifiers re-check the operator signatures over those same +// exact bytes. Carried verbatim as SignedTransitionMessage.body; the +// coordinator signature covers the domain-tagged form of these bytes +// (domain_tag || body), not the bare bytes. +message TransitionMessageBody { + bytes attempt_context_hash = 1; + uint32 coordinator_id = 2; + repeated bytes signed_snapshots = 3; +} + +// The on-wire transition message: the exact serialized TransitionMessageBody +// bytes plus the coordinator signature, which covers the domain-tagged body +// (domain_tag || body), not the bare body field. +message SignedTransitionMessage { + bytes body = 1; + bytes coordinator_signature = 2; +} diff --git a/pkg/frost/roast/gen/pb/share_submission.pb.go b/pkg/frost/roast/gen/pb/share_submission.pb.go new file mode 100644 index 0000000000..224353a4fa --- /dev/null +++ b/pkg/frost/roast/gen/pb/share_submission.pb.go @@ -0,0 +1,280 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.3 +// protoc v6.33.4 +// source: pkg/frost/roast/gen/pb/share_submission.proto + +package pb + +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) +) + +// Member-authenticated Round2 share submission wire format (RFC-21 Phase 7.2b). +// +// After a member authenticates the elected coordinator's SignedSigningPackage +// and accepts its taproot root, it returns its FROST round-2 signature share +// bound to that exact package: the body commits to the attempt, the hash of the +// SignedSigningPackage envelope it signed over, and the share itself, and the +// member signs the whole with its operator key. This binding is what makes +// blame adjudicable (RFC-21 Phase 7.2b-4): a member's share is provably tied to +// the specific package bytes it received, so a coordinator that equivocated +// (distributing different packages to different members) is detectable, and a +// member's submission is non-repudiable. +// +// Same signed-body discipline as the other ROAST envelopes: the body travels +// verbatim and the signature is verified over exactly the received bytes, so +// validity never depends on a serializer's canonical form across protobuf +// versions or languages. +// +// SIGNED PAYLOAD (cross-language contract). The submitter signature in +// SignedShareSubmission.submitter_signature covers a domain-separated byte +// stream, NOT the bare body: +// +// domain_tag || serialized ShareSubmissionBody +// +// where domain_tag is, in order: a single 0x00 byte, the fixed ASCII bytes +// "roast/signed-share-submission/v1", and a trailing 0x00 byte. The leading +// 0x00 is an illegal protobuf tag (field number 0), so the signed payload is +// undecodable as any protobuf message and a share-submission signature can +// never be accepted for another coordinator/operator-signed body (or vice +// versa). The tag is a constant prepended by signer and verifier alike and is +// NOT carried on the wire - only the body is, in SignedShareSubmission.body. +type ShareSubmissionBody struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 32-byte attempt context hash binding the submission to one attempt. + AttemptContextHash []byte `protobuf:"bytes,1,opt,name=attempt_context_hash,json=attemptContextHash,proto3" json:"attempt_context_hash,omitempty"` + // The submitting member's index (RFC-21 Annex A). A verifier checks the + // submitter signature under this member's operator key. + SubmitterId uint32 `protobuf:"varint,2,opt,name=submitter_id,json=submitterId,proto3" json:"submitter_id,omitempty"` + // The elected coordinator this share is authorized for, as the member + // resolved it while authenticating the signing package. Declared explicitly + // (not only implied by signing_package_hash) so the share is self-describing + // for blame and non-repudiation; the member-side check rejects a share whose + // coordinator_id disagrees with the bound package. + CoordinatorId uint32 `protobuf:"varint,3,opt,name=coordinator_id,json=coordinatorId,proto3" json:"coordinator_id,omitempty"` + // 32-byte SHA-256 of the signing-package BODY this share responds to - the + // serialized SigningPackageBody the coordinator signed (NOT the on-wire + // envelope). Binds the share to the coordinator's instruction. Hashing the + // body, not the envelope, keeps the binding stable across unsigned envelope + // re-encodings (the coordinator signature does not cover the outer envelope), + // so the same instruction maps to one binding for every member and coordinator + // equivocation (two different signed bodies for one attempt) is detected + // without false positives. See SigningPackage.BodyHash. + SigningPackageHash []byte `protobuf:"bytes,4,opt,name=signing_package_hash,json=signingPackageHash,proto3" json:"signing_package_hash,omitempty"` + // The serialized FROST round-2 signature share. + SignatureShare []byte `protobuf:"bytes,5,opt,name=signature_share,json=signatureShare,proto3" json:"signature_share,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ShareSubmissionBody) Reset() { + *x = ShareSubmissionBody{} + mi := &file_pkg_frost_roast_gen_pb_share_submission_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ShareSubmissionBody) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ShareSubmissionBody) ProtoMessage() {} + +func (x *ShareSubmissionBody) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_share_submission_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ShareSubmissionBody.ProtoReflect.Descriptor instead. +func (*ShareSubmissionBody) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_share_submission_proto_rawDescGZIP(), []int{0} +} + +func (x *ShareSubmissionBody) GetAttemptContextHash() []byte { + if x != nil { + return x.AttemptContextHash + } + return nil +} + +func (x *ShareSubmissionBody) GetSubmitterId() uint32 { + if x != nil { + return x.SubmitterId + } + return 0 +} + +func (x *ShareSubmissionBody) GetCoordinatorId() uint32 { + if x != nil { + return x.CoordinatorId + } + return 0 +} + +func (x *ShareSubmissionBody) GetSigningPackageHash() []byte { + if x != nil { + return x.SigningPackageHash + } + return nil +} + +func (x *ShareSubmissionBody) GetSignatureShare() []byte { + if x != nil { + return x.SignatureShare + } + return nil +} + +// The on-wire share submission: the exact serialized ShareSubmissionBody bytes +// plus the submitting member's operator signature, which covers the +// domain-tagged body (domain_tag || body), not the bare body field. +type SignedShareSubmission struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body []byte `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + SubmitterSignature []byte `protobuf:"bytes,2,opt,name=submitter_signature,json=submitterSignature,proto3" json:"submitter_signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignedShareSubmission) Reset() { + *x = SignedShareSubmission{} + mi := &file_pkg_frost_roast_gen_pb_share_submission_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignedShareSubmission) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignedShareSubmission) ProtoMessage() {} + +func (x *SignedShareSubmission) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_share_submission_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SignedShareSubmission.ProtoReflect.Descriptor instead. +func (*SignedShareSubmission) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_share_submission_proto_rawDescGZIP(), []int{1} +} + +func (x *SignedShareSubmission) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *SignedShareSubmission) GetSubmitterSignature() []byte { + if x != nil { + return x.SubmitterSignature + } + return nil +} + +var File_pkg_frost_roast_gen_pb_share_submission_proto protoreflect.FileDescriptor + +var file_pkg_frost_roast_gen_pb_share_submission_proto_rawDesc = []byte{ + 0x0a, 0x2d, 0x70, 0x6b, 0x67, 0x2f, 0x66, 0x72, 0x6f, 0x73, 0x74, 0x2f, 0x72, 0x6f, 0x61, 0x73, + 0x74, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x62, 0x2f, 0x73, 0x68, 0x61, 0x72, 0x65, 0x5f, 0x73, + 0x75, 0x62, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x05, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x22, 0xec, 0x01, 0x0a, 0x13, 0x53, 0x68, 0x61, 0x72, 0x65, + 0x53, 0x75, 0x62, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x30, + 0x0a, 0x14, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, + 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x12, 0x61, 0x74, + 0x74, 0x65, 0x6d, 0x70, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x48, 0x61, 0x73, 0x68, + 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, + 0x72, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, + 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x63, 0x6f, 0x6f, + 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x73, 0x69, + 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x5f, 0x68, 0x61, + 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x12, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, + 0x67, 0x50, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x48, 0x61, 0x73, 0x68, 0x12, 0x27, 0x0a, 0x0f, + 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x73, 0x68, 0x61, 0x72, 0x65, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x53, 0x68, 0x61, 0x72, 0x65, 0x22, 0x5c, 0x0a, 0x15, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x53, + 0x68, 0x61, 0x72, 0x65, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, + 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, + 0x64, 0x79, 0x12, 0x2f, 0x0a, 0x13, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x72, 0x5f, + 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x12, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x72, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, + 0x75, 0x72, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, +} + +var ( + file_pkg_frost_roast_gen_pb_share_submission_proto_rawDescOnce sync.Once + file_pkg_frost_roast_gen_pb_share_submission_proto_rawDescData = file_pkg_frost_roast_gen_pb_share_submission_proto_rawDesc +) + +func file_pkg_frost_roast_gen_pb_share_submission_proto_rawDescGZIP() []byte { + file_pkg_frost_roast_gen_pb_share_submission_proto_rawDescOnce.Do(func() { + file_pkg_frost_roast_gen_pb_share_submission_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_frost_roast_gen_pb_share_submission_proto_rawDescData) + }) + return file_pkg_frost_roast_gen_pb_share_submission_proto_rawDescData +} + +var file_pkg_frost_roast_gen_pb_share_submission_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pkg_frost_roast_gen_pb_share_submission_proto_goTypes = []any{ + (*ShareSubmissionBody)(nil), // 0: roast.ShareSubmissionBody + (*SignedShareSubmission)(nil), // 1: roast.SignedShareSubmission +} +var file_pkg_frost_roast_gen_pb_share_submission_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] 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_pkg_frost_roast_gen_pb_share_submission_proto_init() } +func file_pkg_frost_roast_gen_pb_share_submission_proto_init() { + if File_pkg_frost_roast_gen_pb_share_submission_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_pkg_frost_roast_gen_pb_share_submission_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pkg_frost_roast_gen_pb_share_submission_proto_goTypes, + DependencyIndexes: file_pkg_frost_roast_gen_pb_share_submission_proto_depIdxs, + MessageInfos: file_pkg_frost_roast_gen_pb_share_submission_proto_msgTypes, + }.Build() + File_pkg_frost_roast_gen_pb_share_submission_proto = out.File + file_pkg_frost_roast_gen_pb_share_submission_proto_rawDesc = nil + file_pkg_frost_roast_gen_pb_share_submission_proto_goTypes = nil + file_pkg_frost_roast_gen_pb_share_submission_proto_depIdxs = nil +} diff --git a/pkg/frost/roast/gen/pb/share_submission.proto b/pkg/frost/roast/gen/pb/share_submission.proto new file mode 100644 index 0000000000..02c0abdba8 --- /dev/null +++ b/pkg/frost/roast/gen/pb/share_submission.proto @@ -0,0 +1,67 @@ +syntax = "proto3"; + +option go_package = "./pb"; +package roast; + +// Member-authenticated Round2 share submission wire format (RFC-21 Phase 7.2b). +// +// After a member authenticates the elected coordinator's SignedSigningPackage +// and accepts its taproot root, it returns its FROST round-2 signature share +// bound to that exact package: the body commits to the attempt, the hash of the +// SignedSigningPackage envelope it signed over, and the share itself, and the +// member signs the whole with its operator key. This binding is what makes +// blame adjudicable (RFC-21 Phase 7.2b-4): a member's share is provably tied to +// the specific package bytes it received, so a coordinator that equivocated +// (distributing different packages to different members) is detectable, and a +// member's submission is non-repudiable. +// +// Same signed-body discipline as the other ROAST envelopes: the body travels +// verbatim and the signature is verified over exactly the received bytes, so +// validity never depends on a serializer's canonical form across protobuf +// versions or languages. +// +// SIGNED PAYLOAD (cross-language contract). The submitter signature in +// SignedShareSubmission.submitter_signature covers a domain-separated byte +// stream, NOT the bare body: +// +// domain_tag || serialized ShareSubmissionBody +// +// where domain_tag is, in order: a single 0x00 byte, the fixed ASCII bytes +// "roast/signed-share-submission/v1", and a trailing 0x00 byte. The leading +// 0x00 is an illegal protobuf tag (field number 0), so the signed payload is +// undecodable as any protobuf message and a share-submission signature can +// never be accepted for another coordinator/operator-signed body (or vice +// versa). The tag is a constant prepended by signer and verifier alike and is +// NOT carried on the wire - only the body is, in SignedShareSubmission.body. +message ShareSubmissionBody { + // 32-byte attempt context hash binding the submission to one attempt. + bytes attempt_context_hash = 1; + // The submitting member's index (RFC-21 Annex A). A verifier checks the + // submitter signature under this member's operator key. + uint32 submitter_id = 2; + // The elected coordinator this share is authorized for, as the member + // resolved it while authenticating the signing package. Declared explicitly + // (not only implied by signing_package_hash) so the share is self-describing + // for blame and non-repudiation; the member-side check rejects a share whose + // coordinator_id disagrees with the bound package. + uint32 coordinator_id = 3; + // 32-byte SHA-256 of the signing-package BODY this share responds to - the + // serialized SigningPackageBody the coordinator signed (NOT the on-wire + // envelope). Binds the share to the coordinator's instruction. Hashing the + // body, not the envelope, keeps the binding stable across unsigned envelope + // re-encodings (the coordinator signature does not cover the outer envelope), + // so the same instruction maps to one binding for every member and coordinator + // equivocation (two different signed bodies for one attempt) is detected + // without false positives. See SigningPackage.BodyHash. + bytes signing_package_hash = 4; + // The serialized FROST round-2 signature share. + bytes signature_share = 5; +} + +// The on-wire share submission: the exact serialized ShareSubmissionBody bytes +// plus the submitting member's operator signature, which covers the +// domain-tagged body (domain_tag || body), not the bare body field. +message SignedShareSubmission { + bytes body = 1; + bytes submitter_signature = 2; +} diff --git a/pkg/frost/roast/gen/pb/signing_package.pb.go b/pkg/frost/roast/gen/pb/signing_package.pb.go new file mode 100644 index 0000000000..e2ca93940e --- /dev/null +++ b/pkg/frost/roast/gen/pb/signing_package.pb.go @@ -0,0 +1,251 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.3 +// protoc v6.33.4 +// source: pkg/frost/roast/gen/pb/signing_package.proto + +package pb + +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) +) + +// The signing-package body. Carried verbatim as SignedSigningPackage.body; +// the operator signature covers the domain-tagged form of these bytes +// (domain_tag || body), not the bare bytes. +type SigningPackageBody struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 32-byte attempt context hash binding the package to one attempt. + AttemptContextHash []byte `protobuf:"bytes,1,opt,name=attempt_context_hash,json=attemptContextHash,proto3" json:"attempt_context_hash,omitempty"` + // The elected coordinator's member index (RFC-21 Annex A). A member + // verifies this is the attempt's elected coordinator and that the + // signature verifies under that coordinator's operator key. + CoordinatorId uint32 `protobuf:"varint,2,opt,name=coordinator_id,json=coordinatorId,proto3" json:"coordinator_id,omitempty"` + // The serialized FROST SigningPackage the chosen subset signs over. + SigningPackage []byte `protobuf:"bytes,3,opt,name=signing_package,json=signingPackage,proto3" json:"signing_package,omitempty"` + // The 32-byte taproot script-tree root the signature is tweaked by; + // empty for a key-path spend. + TaprootMerkleRoot []byte `protobuf:"bytes,4,opt,name=taproot_merkle_root,json=taprootMerkleRoot,proto3" json:"taproot_merkle_root,omitempty"` + // The member indices of the chosen signing subset the FROST signing_package + // was built over (RFC-21 Phase 7.3 t-of-included finalize): ascending, + // distinct, each a valid member index. It lets non-coordinators know which + // members to await round-2 shares from when the package covers a t-subset of + // the included set. The cryptographic source of truth is signing_package + // itself, so a coordinator that lies here causes only a LIVENESS failure + // (aggregate fails closed when the shares do not match the package), never a + // wrong signature or false blame. Empty for packages that do not carry a + // subset (the full-included flow). + SignerIds []uint32 `protobuf:"varint,5,rep,packed,name=signer_ids,json=signerIds,proto3" json:"signer_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SigningPackageBody) Reset() { + *x = SigningPackageBody{} + mi := &file_pkg_frost_roast_gen_pb_signing_package_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SigningPackageBody) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SigningPackageBody) ProtoMessage() {} + +func (x *SigningPackageBody) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_signing_package_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SigningPackageBody.ProtoReflect.Descriptor instead. +func (*SigningPackageBody) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_signing_package_proto_rawDescGZIP(), []int{0} +} + +func (x *SigningPackageBody) GetAttemptContextHash() []byte { + if x != nil { + return x.AttemptContextHash + } + return nil +} + +func (x *SigningPackageBody) GetCoordinatorId() uint32 { + if x != nil { + return x.CoordinatorId + } + return 0 +} + +func (x *SigningPackageBody) GetSigningPackage() []byte { + if x != nil { + return x.SigningPackage + } + return nil +} + +func (x *SigningPackageBody) GetTaprootMerkleRoot() []byte { + if x != nil { + return x.TaprootMerkleRoot + } + return nil +} + +func (x *SigningPackageBody) GetSignerIds() []uint32 { + if x != nil { + return x.SignerIds + } + return nil +} + +// The on-wire signing package: the exact serialized SigningPackageBody bytes +// plus the elected coordinator's operator signature. The signature covers the +// domain-tagged body (domain_tag || body), not the bare body field. +type SignedSigningPackage struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body []byte `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + CoordinatorSignature []byte `protobuf:"bytes,2,opt,name=coordinator_signature,json=coordinatorSignature,proto3" json:"coordinator_signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignedSigningPackage) Reset() { + *x = SignedSigningPackage{} + mi := &file_pkg_frost_roast_gen_pb_signing_package_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignedSigningPackage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignedSigningPackage) ProtoMessage() {} + +func (x *SignedSigningPackage) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_signing_package_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SignedSigningPackage.ProtoReflect.Descriptor instead. +func (*SignedSigningPackage) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_signing_package_proto_rawDescGZIP(), []int{1} +} + +func (x *SignedSigningPackage) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *SignedSigningPackage) GetCoordinatorSignature() []byte { + if x != nil { + return x.CoordinatorSignature + } + return nil +} + +var File_pkg_frost_roast_gen_pb_signing_package_proto protoreflect.FileDescriptor + +var file_pkg_frost_roast_gen_pb_signing_package_proto_rawDesc = []byte{ + 0x0a, 0x2c, 0x70, 0x6b, 0x67, 0x2f, 0x66, 0x72, 0x6f, 0x73, 0x74, 0x2f, 0x72, 0x6f, 0x61, 0x73, + 0x74, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x62, 0x2f, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, + 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, + 0x72, 0x6f, 0x61, 0x73, 0x74, 0x22, 0xe5, 0x01, 0x0a, 0x12, 0x53, 0x69, 0x67, 0x6e, 0x69, 0x6e, + 0x67, 0x50, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x30, 0x0a, 0x14, + 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, + 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x12, 0x61, 0x74, 0x74, 0x65, + 0x6d, 0x70, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x25, + 0x0a, 0x0e, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, + 0x74, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x0f, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, + 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, + 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x50, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x12, 0x2e, + 0x0a, 0x13, 0x74, 0x61, 0x70, 0x72, 0x6f, 0x6f, 0x74, 0x5f, 0x6d, 0x65, 0x72, 0x6b, 0x6c, 0x65, + 0x5f, 0x72, 0x6f, 0x6f, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x61, 0x70, + 0x72, 0x6f, 0x6f, 0x74, 0x4d, 0x65, 0x72, 0x6b, 0x6c, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x1d, + 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, + 0x28, 0x0d, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x73, 0x22, 0x5f, 0x0a, + 0x14, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x50, 0x61, + 0x63, 0x6b, 0x61, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x33, 0x0a, 0x15, 0x63, 0x6f, 0x6f, + 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x14, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, + 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x42, 0x06, + 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_pkg_frost_roast_gen_pb_signing_package_proto_rawDescOnce sync.Once + file_pkg_frost_roast_gen_pb_signing_package_proto_rawDescData = file_pkg_frost_roast_gen_pb_signing_package_proto_rawDesc +) + +func file_pkg_frost_roast_gen_pb_signing_package_proto_rawDescGZIP() []byte { + file_pkg_frost_roast_gen_pb_signing_package_proto_rawDescOnce.Do(func() { + file_pkg_frost_roast_gen_pb_signing_package_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_frost_roast_gen_pb_signing_package_proto_rawDescData) + }) + return file_pkg_frost_roast_gen_pb_signing_package_proto_rawDescData +} + +var file_pkg_frost_roast_gen_pb_signing_package_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pkg_frost_roast_gen_pb_signing_package_proto_goTypes = []any{ + (*SigningPackageBody)(nil), // 0: roast.SigningPackageBody + (*SignedSigningPackage)(nil), // 1: roast.SignedSigningPackage +} +var file_pkg_frost_roast_gen_pb_signing_package_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] 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_pkg_frost_roast_gen_pb_signing_package_proto_init() } +func file_pkg_frost_roast_gen_pb_signing_package_proto_init() { + if File_pkg_frost_roast_gen_pb_signing_package_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_pkg_frost_roast_gen_pb_signing_package_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pkg_frost_roast_gen_pb_signing_package_proto_goTypes, + DependencyIndexes: file_pkg_frost_roast_gen_pb_signing_package_proto_depIdxs, + MessageInfos: file_pkg_frost_roast_gen_pb_signing_package_proto_msgTypes, + }.Build() + File_pkg_frost_roast_gen_pb_signing_package_proto = out.File + file_pkg_frost_roast_gen_pb_signing_package_proto_rawDesc = nil + file_pkg_frost_roast_gen_pb_signing_package_proto_goTypes = nil + file_pkg_frost_roast_gen_pb_signing_package_proto_depIdxs = nil +} diff --git a/pkg/frost/roast/gen/pb/signing_package.proto b/pkg/frost/roast/gen/pb/signing_package.proto new file mode 100644 index 0000000000..59afc68cdb --- /dev/null +++ b/pkg/frost/roast/gen/pb/signing_package.proto @@ -0,0 +1,66 @@ +syntax = "proto3"; + +option go_package = "./pb"; +package roast; + +// Signed signing-package wire format (RFC-21 Phase 7.2b). +// +// The elected coordinator signs the signing package with its operator key and +// distributes the SignedSigningPackage to the chosen signing subset. A member +// verifies the coordinator signature over the bytes it received and only then +// parses them - the same signed-body, verify-what-you-received discipline as +// the evidence envelopes (evidence.proto): the body travels verbatim, so +// signature validity never depends on any serializer's canonical form across +// protobuf versions or languages. +// +// SIGNED PAYLOAD (cross-language contract). The operator signature in +// SignedSigningPackage.coordinator_signature is computed over a +// domain-separated byte stream, NOT over the bare body: +// +// domain_tag || serialized SigningPackageBody +// +// where domain_tag is, in order: a single 0x00 byte, the fixed ASCII bytes +// "roast/signed-signing-package/v1", and a trailing 0x00 byte. The LEADING +// 0x00 is an illegal protobuf tag (field number 0): it makes the signed +// payload undecodable as any protobuf message, so a signing-package signature +// can never be accepted on a transition-message (or other coordinator-signed) +// envelope whose body is otherwise wire-compatible with SigningPackageBody. +// The tag is a constant prepended by signer and verifier alike and is NOT +// carried on the wire - only the body is, in SignedSigningPackage.body. Any +// implementation that signs or verifies the bare body (without the exact tag) +// will fail to interoperate. + +// The signing-package body. Carried verbatim as SignedSigningPackage.body; +// the operator signature covers the domain-tagged form of these bytes +// (domain_tag || body), not the bare bytes. +message SigningPackageBody { + // 32-byte attempt context hash binding the package to one attempt. + bytes attempt_context_hash = 1; + // The elected coordinator's member index (RFC-21 Annex A). A member + // verifies this is the attempt's elected coordinator and that the + // signature verifies under that coordinator's operator key. + uint32 coordinator_id = 2; + // The serialized FROST SigningPackage the chosen subset signs over. + bytes signing_package = 3; + // The 32-byte taproot script-tree root the signature is tweaked by; + // empty for a key-path spend. + bytes taproot_merkle_root = 4; + // The member indices of the chosen signing subset the FROST signing_package + // was built over (RFC-21 Phase 7.3 t-of-included finalize): ascending, + // distinct, each a valid member index. It lets non-coordinators know which + // members to await round-2 shares from when the package covers a t-subset of + // the included set. The cryptographic source of truth is signing_package + // itself, so a coordinator that lies here causes only a LIVENESS failure + // (aggregate fails closed when the shares do not match the package), never a + // wrong signature or false blame. Empty for packages that do not carry a + // subset (the full-included flow). + repeated uint32 signer_ids = 5; +} + +// The on-wire signing package: the exact serialized SigningPackageBody bytes +// plus the elected coordinator's operator signature. The signature covers the +// domain-tagged body (domain_tag || body), not the bare body field. +message SignedSigningPackage { + bytes body = 1; + bytes coordinator_signature = 2; +} diff --git a/pkg/frost/roast/multi_coordinator_soak_test.go b/pkg/frost/roast/multi_coordinator_soak_test.go new file mode 100644 index 0000000000..333c68a75f --- /dev/null +++ b/pkg/frost/roast/multi_coordinator_soak_test.go @@ -0,0 +1,454 @@ +package roast + +import ( + "bytes" + "crypto/sha256" + "errors" + "sort" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// The soak harness models the production deployment: every signer +// runs its own Coordinator instance bound to its own selfMember, +// shares the same signer/verifier scheme (here a deterministic +// SHA-256 stand-in), and must compute byte-identical next contexts +// given the same verified TransitionMessage. +// +// The harness exercises RFC-21 Layer A (overflow exclusion), Layer +// B (silence parking + reinstatement), and the policy's +// infeasibility floor under synthetic fault injection. The receive +// loops are bypassed -- they are unit-tested elsewhere; what the +// soak harness adds is the multi-instance-agreement property. + +// soakSigner produces SHA-256(member || payload) signatures. The +// matching soakVerifier accepts any signature byte-identical to +// the recomputation, so cross-instance verification works without +// real crypto. +type soakSigner struct { + id group.MemberIndex +} + +func (s *soakSigner) Sign(payload []byte) ([]byte, error) { + h := sha256.New() + h.Write([]byte{byte(s.id)}) + h.Write(payload) + return h.Sum(nil), nil +} + +type soakVerifier struct{} + +func (soakVerifier) Verify(payload, signature []byte, signer group.MemberIndex) error { + h := sha256.New() + h.Write([]byte{byte(signer)}) + h.Write(payload) + want := h.Sum(nil) + if !bytes.Equal(want, signature) { + return errors.New("soakVerifier: signature does not match recomputation") + } + return nil +} + +// soakNode bundles one signer's Coordinator instance, its self +// signer, and the snapshot it submits each attempt. +type soakNode struct { + self group.MemberIndex + coord Coordinator + signer *soakSigner +} + +// newSoakHarness initialises N coordinator instances bound to +// member indices 1..N, ready to BeginAttempt against a shared +// AttemptContext. Returns the nodes plus a deterministic +// shared-state baseline attempt context. +func newSoakHarness( + t *testing.T, + members []group.MemberIndex, +) []*soakNode { + t.Helper() + nodes := make([]*soakNode, 0, len(members)) + for _, m := range members { + signer := &soakSigner{id: m} + node := &soakNode{ + self: m, + coord: NewInMemoryCoordinatorWithSigning(m, signer, soakVerifier{}), + signer: signer, + } + nodes = append(nodes, node) + } + return nodes +} + +// soakAttempt drives a full attempt across every node: +// +// 1. Every node calls BeginAttempt with the shared context. +// 2. Every node produces a signed snapshot per the fault map +// (silent members produce nil; overflowing members produce +// snapshots with overflow events). +// 3. Every node receives every other node's snapshot via +// RecordEvidence. +// 4. The elected coordinator's node calls AggregateBundle. +// 5. Every non-coordinator node calls VerifyBundle. +// 6. Every node calls NextAttempt against the same verified +// bundle. +// +// Returns the next AttemptContext computed by every node (all must +// be byte-identical) and the elected coordinator's identity for +// the *current* attempt. +// +// silenceFor and overflowFor are maps that let the test inject +// faults. overflowFor[observer] = [senders the observer reports +// having overflowed]. +func soakAttempt( + t *testing.T, + nodes []*soakNode, + ctx attempt.AttemptContext, + silenceFor map[group.MemberIndex]bool, + overflowFor map[group.MemberIndex][]group.MemberIndex, + threshold uint, +) (attempt.AttemptContext, group.MemberIndex) { + t.Helper() + + type beginResult struct { + node *soakNode + handle AttemptHandle + } + begins := make([]beginResult, 0, len(nodes)) + for _, n := range nodes { + h, err := n.coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("node %d BeginAttempt: %v", n.self, err) + } + begins = append(begins, beginResult{node: n, handle: h}) + } + + // Elect coordinator: each node has the same SelectCoordinator + // result for this context, so it doesn't matter which node we + // ask. Use begins[0]. + elected, err := begins[0].node.coord.SelectedCoordinator(begins[0].handle) + if err != nil { + t.Fatalf("SelectedCoordinator: %v", err) + } + + // Each node produces a snapshot unless silent. + type signedSnap struct { + from group.MemberIndex + snapshot *LocalEvidenceSnapshot + } + snaps := make([]signedSnap, 0, len(nodes)) + for _, n := range nodes { + if silenceFor[n.self] { + continue + } + evidence := attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{}, + } + for _, sender := range overflowFor[n.self] { + evidence.Overflows[sender]++ + } + snap := NewLocalEvidenceSnapshot(n.self, ctx.Hash(), evidence) + payload, _ := snap.SignableBytes() + sig, _ := n.signer.Sign(payload) + snap.OperatorSignature = sig + snaps = append(snaps, signedSnap{from: n.self, snapshot: snap}) + } + + // Every node receives every snapshot. + for _, b := range begins { + for _, s := range snaps { + if err := b.node.coord.RecordEvidence(b.handle, s.snapshot); err != nil { + t.Fatalf( + "node %d RecordEvidence from %d: %v", + b.node.self, s.from, err, + ) + } + } + } + + // Find the elected coordinator's node and aggregate. + var aggregator beginResult + for _, b := range begins { + if b.node.self == elected { + aggregator = b + break + } + } + if aggregator.node == nil { + t.Fatalf("elected coordinator %d not in nodes", elected) + } + bundle, err := aggregator.node.coord.AggregateBundle(aggregator.handle) + if err != nil { + t.Fatalf("AggregateBundle on elected node %d: %v", elected, err) + } + + // Every non-coordinator node verifies the bundle. + for _, b := range begins { + if b.node.self == elected { + continue + } + if err := b.node.coord.VerifyBundle(b.handle, bundle); err != nil { + t.Fatalf("node %d VerifyBundle: %v", b.node.self, err) + } + } + + // Every node computes NextAttempt. + dkgPub := []byte{0xab, 0xcd, 0xef} + nextContexts := make([]attempt.AttemptContext, 0, len(nodes)) + for _, b := range begins { + next, err := b.node.coord.NextAttempt( + b.handle, + bundle, + threshold, + dkgPub, + ) + if err != nil { + t.Fatalf("node %d NextAttempt: %v", b.node.self, err) + } + nextContexts = append(nextContexts, next) + } + + // All nodes must produce byte-identical next contexts. + for i := 1; i < len(nextContexts); i++ { + if nextContexts[i].Hash() != nextContexts[0].Hash() { + t.Fatalf( + "multi-instance agreement violated: node 0 hash %x, node %d hash %x", + nextContexts[0].Hash(), + i, + nextContexts[i].Hash(), + ) + } + } + + return nextContexts[0], elected +} + +func soakStartingContext( + t *testing.T, + included []group.MemberIndex, +) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "soak-session", + "soak-key-group", + []byte{0xab, 0xcd, 0xef}, + [attempt.MessageDigestLength]byte{0x99}, + 0, + included, + nil, + ) + if err != nil { + t.Fatalf("starting ctx: %v", err) + } + return ctx +} + +func TestSoak_CleanAttemptPreservesIncludedSet(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + next, _ := soakAttempt(t, nodes, prev, nil, nil, 3) + + if len(next.IncludedSet) != len(members) { + t.Fatalf( + "clean attempt must preserve IncludedSet size; got %d want %d", + len(next.IncludedSet), len(members), + ) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf("clean attempt must not exclude anyone; got %v", next.ExcludedSet) + } + if len(next.TransientlyParked) != 0 { + t.Fatalf("clean attempt must not park anyone; got %v", next.TransientlyParked) + } +} + +func TestSoak_EstablishedOverflowParksTransiently(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + // Four distinct observers report overflow against member 3: + // 4 >= ExclusionAccuserQuorum(5, 3) = 3, so the accusation is + // established -- but transport blame is unverifiable in + // principle, so it parks transiently instead of excluding. + overflow := map[group.MemberIndex][]group.MemberIndex{ + 1: {3}, + 2: {3}, + 4: {3}, + 5: {3}, + } + next, _ := soakAttempt(t, nodes, prev, nil, overflow, 3) + + if !containsMember(next.TransientlyParked, 3) { + t.Fatalf("member 3 must be parked; got %v", next.TransientlyParked) + } + if containsMember(next.ExcludedSet, 3) { + t.Fatalf( + "overflow must never permanently exclude; got %v", + next.ExcludedSet, + ) + } + if containsMember(next.IncludedSet, 3) { + t.Fatal("member 3 must not be in next IncludedSet") + } +} + +func TestSoak_SilenceParksTransiently(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + silence := map[group.MemberIndex]bool{3: true} + next, _ := soakAttempt(t, nodes, prev, silence, nil, 3) + + if !containsMember(next.TransientlyParked, 3) { + t.Fatalf("silent member 3 must be parked; got %v", next.TransientlyParked) + } + if containsMember(next.ExcludedSet, 3) { + t.Fatal("silent member 3 must not be permanently excluded") + } + if containsMember(next.IncludedSet, 3) { + t.Fatal("silent member 3 must not be in next IncludedSet") + } +} + +func TestSoak_ParkedMemberIsReinstatedNextAttempt(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + // Attempt N: member 3 silent → parked at N+1. + silenceN := map[group.MemberIndex]bool{3: true} + contextN1, _ := soakAttempt(t, nodes, prev, silenceN, nil, 3) + if !containsMember(contextN1.TransientlyParked, 3) { + t.Fatalf("setup: N+1 must park member 3; got %v", contextN1.TransientlyParked) + } + + // Attempt N+1: member 3 cannot submit (parked). Other 4 members + // do submit. Need a fresh harness because each node's + // Coordinator already transitioned its previous attempt. + nextNodes := newSoakHarness(t, members) + silenceN1 := map[group.MemberIndex]bool{ + 3: true, // parked by design, cannot submit + } + contextN2, _ := soakAttempt(t, nextNodes, contextN1, silenceN1, nil, 3) + + if !containsMember(contextN2.IncludedSet, 3) { + t.Fatalf("member 3 must be reinstated at N+2; got %v", contextN2.IncludedSet) + } + if containsMember(contextN2.TransientlyParked, 3) { + t.Fatal("member 3 must not be re-parked at N+2") + } + if containsMember(contextN2.ExcludedSet, 3) { + t.Fatal("member 3 must not be permanently excluded at N+2") + } +} + +func TestSoak_TransientSilenceBelowThresholdRecovers(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + // Threshold = 5 (all members required). Silence two members, dropping the + // next attempt's IncludedSet to 3 (= 5 - 2 silenced), below threshold. No + // member is permanently excluded, so NextAttempt must NOT fail: each honest + // node transiently parks the silenced members for reinstatement by a later + // attempt. + silence := map[group.MemberIndex]bool{ + 4: true, + 5: true, + } + // Build the bundle manually because soakAttempt panics on + // NextAttempt error. Walk the same steps but skip the post- + // aggregate verify on infeasibility. + type beginResult struct { + node *soakNode + handle AttemptHandle + } + begins := make([]beginResult, 0, len(nodes)) + for _, n := range nodes { + h, _ := n.coord.BeginAttempt(prev) + begins = append(begins, beginResult{node: n, handle: h}) + } + for _, n := range nodes { + if silence[n.self] { + continue + } + snap := NewLocalEvidenceSnapshot(n.self, prev.Hash(), attempt.Evidence{}) + payload, _ := snap.SignableBytes() + sig, _ := n.signer.Sign(payload) + snap.OperatorSignature = sig + for _, b := range begins { + _ = b.node.coord.RecordEvidence(b.handle, snap) + } + } + elected, _ := begins[0].node.coord.SelectedCoordinator(begins[0].handle) + var aggregator beginResult + for _, b := range begins { + if b.node.self == elected { + aggregator = b + break + } + } + bundle, _ := aggregator.node.coord.AggregateBundle(aggregator.handle) + + // Each honest node's NextAttempt must succeed (not fail closed) and park the + // silenced members transiently rather than excluding them permanently. + for _, b := range begins { + next, err := b.node.coord.NextAttempt(b.handle, bundle, 5, []byte{0x01}) + if err != nil { + t.Fatalf( + "node %d NextAttempt: transient silence must not fail the session; got %v", + b.node.self, err, + ) + } + if !memberSliceContains(next.TransientlyParked, 4) || + !memberSliceContains(next.TransientlyParked, 5) { + t.Fatalf( + "node %d: silenced members 4 and 5 must be transiently parked; got %v", + b.node.self, next.TransientlyParked, + ) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf( + "node %d: silence must not permanently exclude; got %v", + b.node.self, next.ExcludedSet, + ) + } + } +} + +func TestSoak_OriginalSignerSetIsPreservedAcrossThreeTransitions(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + prev := soakStartingContext(t, members) + + // Three attempts back-to-back, with fresh harnesses each + // (real signers run one attempt per Coordinator instance). + for i := 0; i < 3; i++ { + nodes := newSoakHarness(t, members) + next, _ := soakAttempt(t, nodes, prev, nil, nil, 3) + if sz := len(next.IncludedSet) + len(next.ExcludedSet) + len(next.TransientlyParked); sz != len(members) { + t.Fatalf( + "attempt %d: |Inc|+|Exc|+|Park| = %d, want %d", + i, sz, len(members), + ) + } + prev = next + } +} + +func containsMember(slice []group.MemberIndex, target group.MemberIndex) bool { + for _, m := range slice { + if m == target { + return true + } + } + return false +} + +// silence the unused-import warning for sort if no test references +// it directly. +var _ = sort.Slice diff --git a/pkg/frost/roast/next_attempt.go b/pkg/frost/roast/next_attempt.go new file mode 100644 index 0000000000..1865be1d5f --- /dev/null +++ b/pkg/frost/roast/next_attempt.go @@ -0,0 +1,530 @@ +package roast + +import ( + "errors" + "fmt" + "sort" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ExclusionAccuserQuorum returns the number of distinct accusing +// observers required before the NextAttempt policy treats an evidence +// category as group-established against an accused member. +// +// The bundle's evidence entries are observer-signed *claims*, not +// self-incriminating proofs: nothing in an OverflowEntry, RejectEntry, +// or ConflictEntry lets a third party re-check that the accused +// actually misbehaved. A policy that permanently excludes on +// unverifiable claims inverts ROAST's robustness guarantee -- a single +// byzantine observer could fabricate evidence against honest members +// and grind the included set toward ErrAttemptInfeasible. +// +// Under the protocol's own trust assumption (at least threshold of the +// original groupSize members honest), at most +// f = groupSize - threshold members are byzantine. An accusation +// corroborated by at least f+1 distinct observers therefore contains +// at least one honest accuser and can be acted on as established. +// Below the quorum the policy takes no action on the accusation. +// +// A real fault is observed by every honest member processing the +// broadcast (contributions are broadcast, not point-to-point), so +// established faults reach the quorum naturally: with the production +// shape (n=100, t=51) a real fault gathers up to 51 honest accusers +// against a quorum of 50, while the 49 worst-case byzantine members +// can never reach 50 by fabrication. +// +// The gate is intentionally demanding near the assumption boundary: +// at worst-case f = groupSize - threshold, only threshold honest +// observers exist, so establishment needs all but 2t-n-1 of them +// (50 of 51 at the production shape) to have observed the fault and +// landed snapshots in the bundle. In that regime the quorum acts as +// a fabrication firewall rather than a working exclusion mechanism; +// real faults that fewer honest members observe burn retry attempts +// instead (budgeted in RFC-21 Annex B), and proof-carrying blame +// (see the roadmap in NextAttempt) is what restores per-category +// exclusion there. +// +// A zero threshold (used by policy-only tests) or a threshold larger +// than the group yields groupSize+1 -- deliberately unreachable, so no +// accusation-driven action can occur without a real threshold. A +// threshold equal to groupSize (n-of-n) yields a quorum of 1: under +// that shape's own assumption every member is honest (f = 0), so a +// single accusation is established by definition. +func ExclusionAccuserQuorum(groupSize, threshold uint) uint { + if threshold == 0 || threshold > groupSize { + return groupSize + 1 + } + return groupSize - threshold + 1 +} + +// ErrAttemptInfeasible is returned by NextAttempt when PERMANENT exclusions +// leave fewer than threshold non-excluded members, so no future attempt can +// ever reach the signing threshold t and the session can no longer make +// progress with the original signer set. Callers must surface this to the +// application layer: the session is permanently failed. +// +// It is NOT returned merely because the next attempt's IncludedSet falls below +// threshold due to TRANSIENT parking (silence/overflow): parked members are +// reinstated on the following attempt, so a sub-threshold included set burns +// one attempt rather than failing the session. The infeasibility test is on the +// non-excluded (feasible) set, not the post-parking included set. +var ErrAttemptInfeasible = errors.New( + "coordinator: next attempt is infeasible -- non-excluded members below threshold", +) + +// NextAttempt computes the deterministic next attempt context from a +// verified TransitionMessage. It is a pure function of +// (previous AttemptContext, bundle, threshold): two honest signers +// fed the same inputs produce byte-identical outputs, so the +// signing-group state machine remains in agreement across the +// network. +// +// Callers MUST call VerifyBundle on the message before passing it to +// NextAttempt. NextAttempt does not re-run the signature checks; it +// assumes the bundle is verified and only applies the policy. +// +// The policy (RFC-21 Layer B, verifiable-blame revision): +// +// 1. Accusation tallying: each bundle snapshot is one observer's +// claim set. Only observers in the previous attempt's IncludedSet +// are credible (they are the members that participated), each +// observer counts at most once per accused member per category +// regardless of the claimed Count, and only accusations against +// members of the original signer set are tallied. An accusation +// is *established* when at least +// ExclusionAccuserQuorum(originalGroupSize, threshold) distinct +// observers make it; below the quorum it is ignored, because a +// bare counter is not verifiable evidence (see +// ExclusionAccuserQuorum). +// +// 2. Permanent exclusion (validation- or equivocation-blamable): +// members with an established reject accusation or an established +// conflict accusation are added to ExcludedSet forever. The +// categories are tallied independently -- reject and conflict +// claims against the same member do not sum. +// +// 3. Transient parking (transport-blamable): members with an +// established overflow accusation are added to the next attempt's +// TransientlyParked set. Transport pressure is observable only at +// the transport layer and can never be made self-incriminating, +// so overflow can park -- cost one attempt of liveness -- but +// never permanently exclude. +// +// 4. Silence parking (strictly transient): a member in the previous +// attempt's IncludedSet that does not appear in the bundle, and +// is not permanently excluded, is added to the next attempt's +// TransientlyParked set. The attempt after that automatically +// reinstates them, so a falsely-silenced honest peer recovers +// without intervention. +// +// 5. Reinstatement: members in the previous attempt's +// TransientlyParked set automatically rejoin the next attempt's +// IncludedSet (unless they are now permanently excluded). +// +// 6. Infeasibility: if PERMANENT exclusions leave fewer than threshold +// non-excluded members -- so no future attempt can reach the +// threshold -- return ErrAttemptInfeasible. A next IncludedSet that +// falls below threshold only because of TRANSIENT parking is NOT +// infeasible: the parked members rejoin on the following attempt +// (step 5), so it burns one attempt rather than failing the session. +// +// Verifiability roadmap: permanent exclusion on a *single* piece of +// evidence becomes sound once the wire format carries +// self-incriminating proof -- for conflicts, the accused's own two +// operator-signed payloads with identical (attempt, sender) and +// different bytes; for rejects, the accused's operator-signed +// contribution plus a deterministic validation failure any member can +// re-run. When those land, the per-category quorum gate can be +// relaxed to proof-verified entries. Until then the quorum is the +// hard gate that keeps fabricated blame from compounding. +// +// threshold is the FROST signing threshold t for the key group; it +// is constant across attempts within a session. A threshold of zero +// disables the infeasibility check and (via ExclusionAccuserQuorum) +// all accusation-driven action -- useful in tests that exercise the +// silence/parking mechanics independently from threshold semantics. +// +// The caller is responsible for supplying the DKG group public key +// from the same source the previous attempt used (the FFI signer +// material, per RFC-21 Decision 2); a different source would +// silently desynchronise the seed derivation. +func (c *inMemoryCoordinator) NextAttempt( + handle AttemptHandle, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, +) (attempt.AttemptContext, error) { + if bundle == nil { + return attempt.AttemptContext{}, errors.New( + "coordinator: cannot compute next attempt from nil bundle", + ) + } + c.mu.Lock() + record, ok := c.attempts[handle.id] + if !ok { + c.mu.Unlock() + return attempt.AttemptContext{}, ErrUnknownAttempt + } + prev := record.context + c.mu.Unlock() + + return computeNextAttempt(prev, bundle, threshold, dkgGroupPublicKey, c.verifier) +} + +// computeNextAttempt is the policy core: it takes the previous AttemptContext, a +// verified bundle, the signing threshold, and the operator-signature verifier, +// and returns the next AttemptContext. Factored out from NextAttempt so the +// policy is independently unit-testable without a Coordinator instance (tests +// inject a fake verifier). The verifier is used ONLY to authenticate the +// coordinator-signed package proofs carried in the snapshots (proof-carrying +// coordinator-equivocation exclusion); the f+1 accusation tally needs no crypto. +func computeNextAttempt( + prev attempt.AttemptContext, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, + verifier SignatureVerifier, +) (attempt.AttemptContext, error) { + // Original signer set persists across transitions as + // IncludedSet ∪ ExcludedSet ∪ TransientlyParked. Its size (not + // the shrinking IncludedSet) anchors the accuser quorum so the + // bar cannot be lowered by excluding members first. + original := newMemberSet() + original.addAll(prev.IncludedSet) + original.addAll(prev.ExcludedSet) + original.addAll(prev.TransientlyParked) + + quorum := ExclusionAccuserQuorum(uint(original.size()), threshold) + + credibleObservers := newMemberSet() + credibleObservers.addAll(prev.IncludedSet) + + // (1)+(2) Established reject/conflict accusations: permanent. + rejectBlamed := establishedAccused( + bundle, credibleObservers, original, quorum, snapshotRejectAccusations, + ) + conflictBlamed := establishedAccused( + bundle, credibleObservers, original, quorum, snapshotConflictAccusations, + ) + + // (1)+(3) Established overflow accusations: transient parking only. + overflowParked := establishedAccused( + bundle, credibleObservers, original, quorum, snapshotOverflowAccusations, + ) + + // Proof-carrying coordinator equivocation: if the bundle's snapshots carry + // two distinct VALID coordinator-signed packages for this attempt, the + // coordinator equivocated. That is unforgeable (the coordinator's own two + // signatures over different bodies), so it is established INSTANTLY - it does + // NOT pass through the f+1 accuser gate, which exists only to bound + // fabricated bare-counter claims (frozen Phase 7.2b spec, section 6). + coordinatorEquivocators := verifiedCoordinatorEquivocations(bundle, verifier) + + // Merge into permanent exclusion. + exclSet := newMemberSet() + exclSet.addAll(prev.ExcludedSet) + exclSet.addAll(rejectBlamed) + exclSet.addAll(conflictBlamed) + exclSet.addAll(coordinatorEquivocators) + + // (3)+(4) Parking: established overflow accusations plus senders + // in prev.IncludedSet missing from the bundle -- in both cases + // only when not permanently excluded (exclusion wins). + bundleSenders := bundleSenderSet(bundle) + parkSet := newMemberSet() + for _, m := range overflowParked { + if exclSet.contains(m) { + continue + } + parkSet.add(m) + } + for _, m := range prev.IncludedSet { + if bundleSenders.contains(m) { + continue + } + if exclSet.contains(m) { + continue + } + parkSet.add(m) + } + + // (5) Reinstate previously parked members by re-including them + // (unless newly permanently excluded or re-parked). + // + // Feasibility (step 6) is judged against the PERMANENTLY-available set -- + // the original signer set minus permanent exclusions -- NOT the + // post-parking included set. Transiently-parked members (overflow and + // silence) are reinstated by a later attempt (steps 3-4), so counting them + // as gone here would let a single transient mass-silence event, or one + // byzantine elected coordinator that omits snapshots, permanently fail a + // session the original signer set can still complete -- exactly the + // grind-to-ErrAttemptInfeasible failure the accuser quorum exists to + // prevent, here via the ungated silence-parking path. Permanent exclusion + // is the only thing that can render a session infeasible. + feasible := filterOut(original.sorted(), exclSet) + + // (6) Infeasibility check: only when permanent exclusions leave fewer than + // threshold members can no future attempt ever reach the threshold. + if threshold > 0 && uint(len(feasible)) < threshold { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %d non-excluded, threshold %d", + ErrAttemptInfeasible, + len(feasible), + threshold, + ) + } + + // The next attempt's included set is the feasible set minus this attempt's + // transient parking. It may fall below threshold (the parked members are + // reinstated next attempt); that burns one attempt rather than failing the + // session. + included := filterOut(feasible, parkSet) + nextParked := parkSet.sorted() + if len(included) == 0 { + // Every non-excluded member is transiently parked this attempt. Parking + // is strictly transient and an AttemptContext requires a non-empty + // included set, so reinstate the parked members now (they retry this + // attempt) rather than producing an empty, unconstructable attempt. + included = feasible + nextParked = nil + } + + // Convert ExcludedSet to its canonical (sorted, deduped) slice. + nextExcluded := exclSet.sorted() + + next, err := attempt.NewAttemptContextWithParking( + prev.SessionID, + prev.KeyGroupID, + dkgGroupPublicKey, + prev.MessageDigest, + prev.AttemptNumber+1, + included, + nextExcluded, + nextParked, + ) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "coordinator: next attempt construction: %w", + err, + ) + } + return next, nil +} + +// snapshotAccusations extracts the members one observer's snapshot +// accuses in a single evidence category. Implementations must return +// each accused member at most once per snapshot. +type snapshotAccusations func(snapshot *LocalEvidenceSnapshot) []group.MemberIndex + +// snapshotOverflowAccusations returns the members the snapshot accuses +// of transport overflow. The claimed Count magnitude is intentionally +// ignored beyond existence: an observer is one accuser regardless of +// how large a counter it reports. +func snapshotOverflowAccusations( + snapshot *LocalEvidenceSnapshot, +) []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(snapshot.Overflows)) + for _, entry := range snapshot.Overflows { + if entry.Count == 0 { + continue + } + out = append(out, entry.Sender) + } + return out +} + +// snapshotRejectAccusations returns the members the snapshot accuses +// of validation rejects, deduplicated across reasons: one observer +// reporting three reject reasons against the same member is still a +// single accuser for that member. +func snapshotRejectAccusations( + snapshot *LocalEvidenceSnapshot, +) []group.MemberIndex { + seen := newMemberSet() + out := make([]group.MemberIndex, 0, len(snapshot.Rejects)) + for _, entry := range snapshot.Rejects { + if entry.Count == 0 { + continue + } + if seen.contains(entry.Sender) { + continue + } + seen.add(entry.Sender) + out = append(out, entry.Sender) + } + return out +} + +// snapshotConflictAccusations returns the members the snapshot accuses +// of first-write-wins conflicts. +func snapshotConflictAccusations( + snapshot *LocalEvidenceSnapshot, +) []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(snapshot.Conflicts)) + for _, entry := range snapshot.Conflicts { + if entry.Count == 0 { + continue + } + out = append(out, entry.Sender) + } + return out +} + +// verifiedCoordinatorEquivocations returns the elected coordinator if the bundle +// proves it equivocated: two distinct VALID coordinator-signed signing packages +// for this attempt, surfaced (possibly by different members) in the snapshots' +// CoordinatorPackageProofs. Each proof is authenticated as a genuine +// SignedSigningPackage from the elected coordinator binding this attempt - Go +// operator-signature verification, not FROST crypto - so unparseable or +// unauthenticated proofs are ignored, never blamed. Two distinct authentic body +// hashes are unforgeable proof (the coordinator's own two signatures over +// different bodies), so a SINGLE honest observer's bundle entry suffices: no f+1 +// gate. Returns nil when no such proof is present. +func verifiedCoordinatorEquivocations( + bundle *TransitionMessage, + verifier SignatureVerifier, +) []group.MemberIndex { + electedCoordinator := bundle.CoordinatorID() + bodies := make(map[[32]byte]struct{}, 2) + for i := range bundle.Bundle { + for _, proof := range bundle.Bundle[i].CoordinatorPackageProofs { + pkg := &SigningPackage{} + if err := pkg.Unmarshal(proof); err != nil { + continue + } + if err := AuthenticateSigningPackage( + verifier, pkg, electedCoordinator, bundle.AttemptContextHash, + ); err != nil { + continue + } + bodyHash, err := pkg.BodyHash() + if err != nil { + continue + } + bodies[bodyHash] = struct{}{} + if len(bodies) >= 2 { + return []group.MemberIndex{electedCoordinator} + } + } + } + return nil +} + +// establishedAccused tallies one evidence category across the bundle +// by *distinct credible accuser* and returns the deterministically +// sorted accused members whose accuser count meets the quorum. +// +// Bundle validity (one snapshot per sender, ascending) is enforced by +// TransitionMessage.Validate; the memberSet additionally makes +// re-counting harmless. +func establishedAccused( + bundle *TransitionMessage, + credibleObservers *memberSet, + original *memberSet, + quorum uint, + accusations snapshotAccusations, +) []group.MemberIndex { + accusersByAccused := map[group.MemberIndex]*memberSet{} + for i := range bundle.Bundle { + snapshot := &bundle.Bundle[i] + observer := snapshot.SenderID() + if !credibleObservers.contains(observer) { + continue + } + for _, accused := range accusations(snapshot) { + if !original.contains(accused) { + continue + } + accusers, ok := accusersByAccused[accused] + if !ok { + accusers = newMemberSet() + accusersByAccused[accused] = accusers + } + accusers.add(observer) + } + } + + counts := map[group.MemberIndex]uint{} + for accused, accusers := range accusersByAccused { + counts[accused] = uint(accusers.size()) + } + return blamedSenders(counts, quorum) +} + +// blamedSenders extracts the deterministically-sorted list of +// senders whose accumulated count meets the threshold. Factored +// out so the category helpers share the same canonicalisation. +func blamedSenders( + counts map[group.MemberIndex]uint, + threshold uint, +) []group.MemberIndex { + out := make([]group.MemberIndex, 0) + for sender, count := range counts { + if count >= threshold { + out = append(out, sender) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// bundleSenderSet returns the set of senders that submitted a +// snapshot to the bundle. +func bundleSenderSet(bundle *TransitionMessage) *memberSet { + out := newMemberSet() + for i := range bundle.Bundle { + out.add(bundle.Bundle[i].SenderID()) + } + return out +} + +// memberSet is a small helper for set arithmetic over +// group.MemberIndex. Sufficient for the small (≤256) sizes the +// coordinator deals with. +type memberSet struct { + m map[group.MemberIndex]struct{} +} + +func newMemberSet() *memberSet { + return &memberSet{m: map[group.MemberIndex]struct{}{}} +} + +func (s *memberSet) add(member group.MemberIndex) { s.m[member] = struct{}{} } +func (s *memberSet) contains(m group.MemberIndex) bool { + _, ok := s.m[m] + return ok +} + +func (s *memberSet) addAll(members []group.MemberIndex) { + for _, m := range members { + s.add(m) + } +} + +func (s *memberSet) size() int { return len(s.m) } + +func (s *memberSet) sorted() []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(s.m)) + for m := range s.m { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// filterOut returns members not in the excluded set, preserving +// input order. +func filterOut( + members []group.MemberIndex, + excluded *memberSet, +) []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(members)) + for _, m := range members { + if !excluded.contains(m) { + out = append(out, m) + } + } + return out +} diff --git a/pkg/frost/roast/next_attempt_categories_test.go b/pkg/frost/roast/next_attempt_categories_test.go new file mode 100644 index 0000000000..ac0d8ee4e1 --- /dev/null +++ b/pkg/frost/roast/next_attempt_categories_test.go @@ -0,0 +1,277 @@ +package roast + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// buildBundleWithCategories constructs a TransitionMessage where +// every member of prev.IncludedSet acts as an observer contributing +// the same per-(category, sender) evidence -- one reject reason and +// one conflict per "blamed" sender per observer. With the full +// included set accusing, every accusation is established (accusers = +// n >= quorum), so these bundles exercise the established-blame +// paths. +func buildBundleWithCategories( + t *testing.T, + prev attempt.AttemptContext, + rejects map[group.MemberIndex][]string, + conflicts []group.MemberIndex, +) *TransitionMessage { + t.Helper() + return buildBundleWithCategoriesFromAccusers( + t, prev, prev.IncludedSet, rejects, conflicts, + ) +} + +// buildBundleWithCategoriesFromAccusers is buildBundleWithCategories +// with an explicit accuser subset: every member of prev.IncludedSet +// submits a snapshot, but only the listed accusers carry the +// reject/conflict evidence. Lets tests pin the accuser-quorum +// boundary precisely. +func buildBundleWithCategoriesFromAccusers( + t *testing.T, + prev attempt.AttemptContext, + accusers []group.MemberIndex, + rejects map[group.MemberIndex][]string, + conflicts []group.MemberIndex, +) *TransitionMessage { + t.Helper() + accuserSet := newMemberSet() + accuserSet.addAll(accusers) + prevHash := prev.Hash() + bundle := make([]LocalEvidenceSnapshot, 0, len(prev.IncludedSet)) + for _, sender := range prev.IncludedSet { + if !accuserSet.contains(sender) { + bundle = append(bundle, LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, prevHash[:]...), + }) + continue + } + snap := LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, prevHash[:]...), + } + var rejectEntries []RejectEntry + for blamedSender, reasons := range rejects { + for _, r := range reasons { + rejectEntries = append(rejectEntries, RejectEntry{ + Sender: blamedSender, + Reason: r, + Count: 1, + }) + } + } + sortRejectEntriesForTest(rejectEntries) + if len(rejectEntries) > 0 { + snap.Rejects = rejectEntries + } + var conflictEntries []ConflictEntry + for _, blamedSender := range conflicts { + conflictEntries = append(conflictEntries, ConflictEntry{ + Sender: blamedSender, + Count: 1, + }) + } + if len(conflictEntries) > 0 { + snap.Conflicts = conflictEntries + } + bundle = append(bundle, snap) + } + return &TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: 1, + Bundle: bundle, + } +} + +func sortRejectEntriesForTest(entries []RejectEntry) { + for i := 1; i < len(entries); i++ { + for j := i; j > 0 && (entries[j].Sender < entries[j-1].Sender || + (entries[j].Sender == entries[j-1].Sender && entries[j].Reason < entries[j-1].Reason)); j-- { + entries[j], entries[j-1] = entries[j-1], entries[j] + } + } +} + +func TestNextAttempt_EstablishedRejectExcludesPermanently(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + // Every observer reports one reject against sender 3: five + // distinct accusers >= quorum (3), so the accusation is + // established and the exclusion is permanent. + bundle := buildBundleWithCategories( + t, + prev, + map[group.MemberIndex][]string{3: {"validation_gate_rejected"}}, + nil, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf("sender 3 must be excluded; got %v", next.ExcludedSet) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatal("sender 3 must not be in next IncludedSet") + } +} + +func TestNextAttempt_EstablishedConflictExcludesPermanently(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := buildBundleWithCategories( + t, + prev, + nil, + []group.MemberIndex{3}, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "sender 3 must be excluded on an established conflict; got %v", + next.ExcludedSet, + ) + } +} + +func TestNextAttempt_SubQuorumRejectAndConflictDoNotExclude(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + // Only two accusers (= f, the tolerated byzantine count) report + // the reject and the conflict: below the quorum (3), so no + // action -- the accused stays included and is not parked. + bundle := buildBundleWithCategoriesFromAccusers( + t, + prev, + []group.MemberIndex{1, 2}, + map[group.MemberIndex][]string{3: {"validation_gate_rejected"}}, + []group.MemberIndex{3}, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "sub-quorum accusations must not exclude; got %v", + next.ExcludedSet, + ) + } + if memberSliceContains(next.TransientlyParked, 3) { + t.Fatalf( + "sub-quorum accusations must not park; got %v", + next.TransientlyParked, + ) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } +} + +func TestNextAttempt_MultipleRejectReasonsAreOneAccuser(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + // Two observers each report three distinct reject reasons against + // sender 3. Reasons must not multiply accusers: 2 accusers < + // quorum (3), no action. Under summed counting this would have + // been 6 >= 1 and an immediate permanent exclusion. + bundle := buildBundleWithCategoriesFromAccusers( + t, + prev, + []group.MemberIndex{1, 2}, + map[group.MemberIndex][]string{ + 3: { + "validation_gate_rejected", + "share_verification_failed", + "commitment_malformed", + }, + }, + nil, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "multiple reasons from few observers must not exclude; got %v", + next.ExcludedSet, + ) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } +} + +func TestNextAttempt_RejectAndConflictBothExclude(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := buildBundleWithCategories( + t, + prev, + map[group.MemberIndex][]string{2: {"validation_gate_rejected"}}, + []group.MemberIndex{4}, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 2) { + t.Fatalf("sender 2 (reject) must be excluded; got %v", next.ExcludedSet) + } + if !memberSliceContains(next.ExcludedSet, 4) { + t.Fatalf("sender 4 (conflict) must be excluded; got %v", next.ExcludedSet) + } +} + +func TestNextAttempt_EmptyRejectsAndConflicts_DoNotExclude(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := buildBundleWithCategories(t, prev, nil, nil) + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf("no evidence -> no exclusions; got %v", next.ExcludedSet) + } +} + +func TestNextAttempt_ExactQuorumOfHonestObserversExcludes(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + // Exactly quorum (3 = f+1) accusers: established. This is the + // boundary at which at least one accuser is guaranteed honest + // under the t-of-n assumption. + bundle := buildBundleWithCategoriesFromAccusers( + t, + prev, + []group.MemberIndex{1, 2, 4}, + map[group.MemberIndex][]string{3: {"validation_gate_rejected"}}, + nil, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "exact quorum must establish the exclusion; got %v", + next.ExcludedSet, + ) + } +} diff --git a/pkg/frost/roast/next_attempt_coordinator_equivocation_test.go b/pkg/frost/roast/next_attempt_coordinator_equivocation_test.go new file mode 100644 index 0000000000..a2b30c5528 --- /dev/null +++ b/pkg/frost/roast/next_attempt_coordinator_equivocation_test.go @@ -0,0 +1,118 @@ +package roast + +import ( + "bytes" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func mustMarshalProof(t *testing.T, pkg *SigningPackage) []byte { + t.Helper() + b, err := pkg.Marshal() + if err != nil { + t.Fatalf("marshal signing package proof: %v", err) + } + return b +} + +// equivocationBundle builds a bundle over the full fixture signer set {1..5} +// (so no member is silence-parked and excluding the coordinator stays feasible), +// bound to pinnedContextHash, with the given per-sender coordinator package +// proofs. +func equivocationBundle(coordinator group.MemberIndex, proofsBySender map[uint32][][]byte) *TransitionMessage { + bundle := make([]LocalEvidenceSnapshot, 0, 5) + for _, s := range []uint32{1, 2, 3, 4, 5} { + bundle = append(bundle, LocalEvidenceSnapshot{ + SenderIDValue: s, + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorPackageProofs: proofsBySender[s], + }) + } + return &TransitionMessage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: uint32(coordinator), + Bundle: bundle, + } +} + +func TestComputeNextAttempt_ProofCarryingCoordinatorEquivocationExcludesCoordinator(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + const coordinator = group.MemberIndex(1) // the fixture's bundle coordinator + + // Coordinator 1 distributed two body-different signing packages for this + // attempt; members 2 and 3 each surface ONE (the targeted/split case, where no + // single observer sees both). Authenticated + distinct => unforgeable + // equivocation => INSTANT exclusion (no f+1 gate). + proofA := mustMarshalProof(t, signedTestSigningPackage(t, coordinator, nil)) + proofB := mustMarshalProof(t, signedTestSigningPackage( + t, coordinator, bytes.Repeat([]byte{0xab}, TaprootMerkleRootLength), + )) + + bundle := equivocationBundle(coordinator, map[uint32][][]byte{ + 2: {proofA}, + 3: {proofB}, + }) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, coordinator) { + t.Fatalf("equivocating coordinator %d must be excluded; excluded=%v", coordinator, next.ExcludedSet) + } + if memberSliceContains(next.IncludedSet, coordinator) { + t.Fatalf("excluded coordinator must drop from the next included set; included=%v", next.IncludedSet) + } +} + +func TestComputeNextAttempt_SingleCoordinatorPackageProofDoesNotExclude(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + const coordinator = group.MemberIndex(1) + + // Every member publishes the ONE package it accepted: legitimate, not + // equivocation. Two members carrying the SAME package is still one distinct + // body. + proof := mustMarshalProof(t, signedTestSigningPackage(t, coordinator, nil)) + bundle := equivocationBundle(coordinator, map[uint32][][]byte{ + 2: {proof}, + 3: {proof}, + }) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, coordinator) { + t.Fatalf("a single (or repeated identical) proof must not exclude the coordinator; excluded=%v", next.ExcludedSet) + } +} + +func TestComputeNextAttempt_UnauthenticatedProofsAreIgnored(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + const coordinator = group.MemberIndex(1) + + // Only authentic, this-coordinator, this-attempt proofs count. A garbage + // envelope and a package signed by a DIFFERENT member (wrong coordinator) are + // ignored - so there are not two distinct VALID bodies, and the coordinator + // is not blamed. + authentic := mustMarshalProof(t, signedTestSigningPackage(t, coordinator, nil)) + wrongCoordinator := mustMarshalProof(t, signedTestSigningPackage( + t, 4, bytes.Repeat([]byte{0xcd}, TaprootMerkleRootLength), + )) + bundle := equivocationBundle(coordinator, map[uint32][][]byte{ + 2: {authentic, []byte("not-a-signing-package")}, + 3: {wrongCoordinator}, + }) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, coordinator) { + t.Fatalf("garbage / wrong-coordinator proofs must be ignored; excluded=%v", next.ExcludedSet) + } +} diff --git a/pkg/frost/roast/next_attempt_test.go b/pkg/frost/roast/next_attempt_test.go new file mode 100644 index 0000000000..1723f6d3ed --- /dev/null +++ b/pkg/frost/roast/next_attempt_test.go @@ -0,0 +1,810 @@ +package roast + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// nextAttemptFixture builds a previous AttemptContext and an +// associated TransitionMessage for the NextAttempt-policy tests. +// Members 1..5 included; no excluded; no parking; threshold 3 (so +// ExclusionAccuserQuorum(5, 3) = 3). By default every member submits +// a snapshot with no evidence entries. The overflows/rejects/conflicts +// maps are keyed by observer, then by accused member. +type nextAttemptFixture struct { + included []group.MemberIndex + excluded []group.MemberIndex + parked []group.MemberIndex + overflows map[group.MemberIndex]map[group.MemberIndex]uint + rejects map[group.MemberIndex]map[group.MemberIndex]uint + conflicts map[group.MemberIndex]map[group.MemberIndex]uint + bundleSenders []group.MemberIndex // override default = included + attemptNumber uint32 + dkgGroupPublicKey []byte + threshold uint + sessionID string + messageDigest [attempt.MessageDigestLength]byte +} + +func newNextAttemptFixture() *nextAttemptFixture { + return &nextAttemptFixture{ + included: []group.MemberIndex{1, 2, 3, 4, 5}, + excluded: nil, + parked: nil, + overflows: map[group.MemberIndex]map[group.MemberIndex]uint{}, + rejects: map[group.MemberIndex]map[group.MemberIndex]uint{}, + conflicts: map[group.MemberIndex]map[group.MemberIndex]uint{}, + bundleSenders: nil, + attemptNumber: 0, + dkgGroupPublicKey: []byte{0x01, 0x02, 0x03}, + threshold: 3, + sessionID: "session-next-attempt", + messageDigest: [attempt.MessageDigestLength]byte{0x42}, + } +} + +func (f *nextAttemptFixture) prev(t *testing.T) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContextWithParking( + f.sessionID, + "key-group-next-attempt", + f.dkgGroupPublicKey, + f.messageDigest, + f.attemptNumber, + f.included, + f.excluded, + f.parked, + ) + if err != nil { + t.Fatalf("fixture prev: %v", err) + } + return ctx +} + +func (f *nextAttemptFixture) bundle(t *testing.T) *TransitionMessage { + t.Helper() + prev := f.prev(t) + prevHash := prev.Hash() + senders := f.bundleSenders + if senders == nil { + senders = append([]group.MemberIndex{}, f.included...) + } + bundle := make([]LocalEvidenceSnapshot, 0, len(senders)) + for _, s := range senders { + snap := LocalEvidenceSnapshot{ + SenderIDValue: uint32(s), + AttemptContextHash: append([]byte{}, prevHash[:]...), + } + if entries, ok := f.overflows[s]; ok { + ov := make([]OverflowEntry, 0, len(entries)) + for sender, count := range entries { + ov = append(ov, OverflowEntry{Sender: sender, Count: count}) + } + snap.Overflows = sortedOverflowEntries(ov) + } + if entries, ok := f.rejects[s]; ok { + rj := make([]RejectEntry, 0, len(entries)) + for sender, count := range entries { + rj = append(rj, RejectEntry{ + Sender: sender, + Reason: "validation_gate_rejected", + Count: count, + }) + } + sortRejectEntriesForTest(rj) + snap.Rejects = rj + } + if entries, ok := f.conflicts[s]; ok { + cf := make([]ConflictEntry, 0, len(entries)) + for sender, count := range entries { + cf = append(cf, ConflictEntry{Sender: sender, Count: count}) + } + sortConflictEntriesForTest(cf) + snap.Conflicts = cf + } + bundle = append(bundle, snap) + } + return &TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: 1, + Bundle: bundle, + } +} + +func sortedOverflowEntries(in []OverflowEntry) []OverflowEntry { + out := append([]OverflowEntry{}, in...) + // insertion sort; small slices. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Sender < out[j-1].Sender; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} + +func sortConflictEntriesForTest(entries []ConflictEntry) { + for i := 1; i < len(entries); i++ { + for j := i; j > 0 && entries[j].Sender < entries[j-1].Sender; j-- { + entries[j], entries[j-1] = entries[j-1], entries[j] + } + } +} + +func TestNextAttempt_NoEvidenceProducesIdenticalIncludedSet(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := f.bundle(t) + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSlicesEqual(next.IncludedSet, prev.IncludedSet) { + t.Fatalf( + "included set changed unexpectedly: prev=%v next=%v", + prev.IncludedSet, next.IncludedSet, + ) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf("excluded set should be empty, got %v", next.ExcludedSet) + } + if len(next.TransientlyParked) != 0 { + t.Fatalf("parking set should be empty, got %v", next.TransientlyParked) + } + if next.AttemptNumber != prev.AttemptNumber+1 { + t.Fatalf( + "attempt number not incremented: got %d, want %d", + next.AttemptNumber, prev.AttemptNumber+1, + ) + } +} + +func TestNextAttempt_EstablishedOverflowParksTransiently(t *testing.T) { + f := newNextAttemptFixture() + // Members 2..5 all report overflow against sender 3: 4 distinct + // accusers >= quorum (3). Transport blame is unverifiable in + // principle, so even an established overflow accusation parks + // transiently and never permanently excludes. + for observer := group.MemberIndex(2); observer <= 5; observer++ { + f.overflows[observer] = map[group.MemberIndex]uint{3: 1} + } + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 should be parked; got included %v", next.IncludedSet) + } + if !memberSliceContains(next.TransientlyParked, 3) { + t.Fatalf( + "sender 3 should appear in parked set; got %v", + next.TransientlyParked, + ) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "overflow must never permanently exclude; got excluded %v", + next.ExcludedSet, + ) + } +} + +func TestNextAttempt_EstablishedOverflowParkIsTransient(t *testing.T) { + f := newNextAttemptFixture() + // Attempt N: quorum overflow accusation against member 3 parks it. + for observer := group.MemberIndex(2); observer <= 5; observer++ { + f.overflows[observer] = map[group.MemberIndex]uint{3: 1} + } + attemptN1, err := computeNextAttempt( + f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}, + ) + if err != nil { + t.Fatalf("N -> N+1: %v", err) + } + if !memberSliceContains(attemptN1.TransientlyParked, 3) { + t.Fatalf("N+1 must park member 3; got %v", attemptN1.TransientlyParked) + } + + // Attempt N+1: parked member 3 cannot submit; no new accusations. + attemptN1Hash := attemptN1.Hash() + bundleN1 := &TransitionMessage{ + AttemptContextHash: append([]byte{}, attemptN1Hash[:]...), + CoordinatorIDValue: 1, + Bundle: []LocalEvidenceSnapshot{ + {SenderIDValue: 1, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 2, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 4, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 5, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + }, + } + attemptN2, err := computeNextAttempt( + attemptN1, bundleN1, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}, + ) + if err != nil { + t.Fatalf("N+1 -> N+2: %v", err) + } + if !memberSliceContains(attemptN2.IncludedSet, 3) { + t.Fatalf( + "N+2 must reinstate overflow-parked member 3; got included %v", + attemptN2.IncludedSet, + ) + } +} + +func TestNextAttempt_SubQuorumOverflowHasNoEffect(t *testing.T) { + f := newNextAttemptFixture() + // Two observers (< quorum 3) report overflow against sender 3, + // with large claimed counts. Counts must not be summed into + // blame: an accusation below the accuser quorum is ignored. + f.overflows[2] = map[group.MemberIndex]uint{3: 100} + f.overflows[4] = map[group.MemberIndex]uint{3: 100} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 should remain included; got %v", next.IncludedSet) + } + if memberSliceContains(next.TransientlyParked, 3) { + t.Fatal("sub-quorum overflow must not park") + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("sub-quorum overflow must not exclude") + } +} + +func TestNextAttempt_SilentMemberIsParkedTransiently(t *testing.T) { + f := newNextAttemptFixture() + // Only members 1, 2, 4, 5 submit; member 3 is silent. + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatal("silent sender 3 must not appear in next IncludedSet") + } + if !memberSliceContains(next.TransientlyParked, 3) { + t.Fatalf("silent sender 3 must appear in next TransientlyParked; got %v", next.TransientlyParked) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("silent sender 3 must not be permanently excluded") + } +} + +func TestNextAttempt_PreviouslyParkedAreReinstated(t *testing.T) { + f := newNextAttemptFixture() + // Previous attempt: members 1, 2, 4, 5 included; member 3 parked. + f.included = []group.MemberIndex{1, 2, 4, 5} + f.parked = []group.MemberIndex{3} + // Bundle: only the included set submits (parked cannot). + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf( + "previously parked member 3 must be reinstated; got included %v", + next.IncludedSet, + ) + } + if memberSliceContains(next.TransientlyParked, 3) { + t.Fatal("member 3 must not be re-parked") + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("member 3 must not be excluded") + } +} + +func TestNextAttempt_ParkingIsStrictlyTransient_NoEscalation(t *testing.T) { + // Demonstrate the full cycle: park, skip one attempt, reinstate. + // Attempt N: member 3 is silent. + // Attempt N+1: member 3 is parked, did not submit. + // Attempt N+2: member 3 is reinstated. + f := newNextAttemptFixture() + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + prev := f.prev(t) + bundle := f.bundle(t) + attemptN1, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("N -> N+1: %v", err) + } + if !memberSliceContains(attemptN1.TransientlyParked, 3) { + t.Fatalf("N+1 must park member 3; got %v", attemptN1.TransientlyParked) + } + if memberSliceContains(attemptN1.IncludedSet, 3) { + t.Fatal("member 3 must not be in N+1 IncludedSet (parked this attempt)") + } + + // Now compute attempt N+2 from a bundle where parked member 3 + // could not submit (legitimately), and members 1, 2, 4, 5 did + // submit. + attemptN1Hash := attemptN1.Hash() + bundleN1 := &TransitionMessage{ + AttemptContextHash: append([]byte{}, attemptN1Hash[:]...), + CoordinatorIDValue: 1, + Bundle: []LocalEvidenceSnapshot{ + {SenderIDValue: 1, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 2, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 4, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 5, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + }, + } + attemptN2, err := computeNextAttempt(attemptN1, bundleN1, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("N+1 -> N+2: %v", err) + } + if !memberSliceContains(attemptN2.IncludedSet, 3) { + t.Fatalf( + "N+2 must reinstate member 3; got included %v", + attemptN2.IncludedSet, + ) + } + if memberSliceContains(attemptN2.TransientlyParked, 3) { + t.Fatal("N+2 must not re-park member 3") + } + if memberSliceContains(attemptN2.ExcludedSet, 3) { + t.Fatal("N+2 must not permanently exclude member 3") + } +} + +func TestNextAttempt_OriginalSignerSetPreservedAcrossTransitions(t *testing.T) { + f := newNextAttemptFixture() + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} // 3 silent + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + originalSize := len(f.included) + nextSize := len(next.IncludedSet) + len(next.ExcludedSet) + len(next.TransientlyParked) + if nextSize != originalSize { + t.Fatalf( + "original signer set size not preserved: %d vs %d", + nextSize, originalSize, + ) + } +} + +func TestNextAttempt_PolicyIsDeterministic(t *testing.T) { + f := newNextAttemptFixture() + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + f.overflows[2] = map[group.MemberIndex]uint{1: 2} + f.overflows[5] = map[group.MemberIndex]uint{1: 2} + a, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("first compute: %v", err) + } + b, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("second compute: %v", err) + } + if a.Hash() != b.Hash() { + t.Fatalf("same inputs produced different next-attempt hashes") + } +} + +func TestNextAttempt_InfeasibilityWhenPermanentExclusionsBelowThreshold(t *testing.T) { + f := newNextAttemptFixture() + f.threshold = 5 // n-of-n: the accuser quorum is 1, so one reject establishes. + // Permanently exclude member 3 via an established reject accusation. Only 4 + // non-excluded members remain, below the threshold of 5, and a permanently + // excluded member is never reinstated -- so the session is genuinely + // infeasible. + f.rejects[1] = map[group.MemberIndex]uint{3: 1} + _, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if !errors.Is(err, ErrAttemptInfeasible) { + t.Fatalf("expected ErrAttemptInfeasible, got %v", err) + } +} + +func TestNextAttempt_TransientSilenceBelowThresholdDoesNotPermanentlyFail(t *testing.T) { + f := newNextAttemptFixture() + f.threshold = 5 // Require all 5 members. + // Silently lose 2 members -> the next attempt's included set drops to 3 + // (below threshold), but NO member is permanently excluded. The session + // must NOT be declared infeasible: the silenced members are transiently + // parked and a later attempt reinstates them. + f.bundleSenders = []group.MemberIndex{1, 2, 3} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("transient silence must not permanently fail the session, got %v", err) + } + if !memberSliceContains(next.TransientlyParked, 4) || + !memberSliceContains(next.TransientlyParked, 5) { + t.Fatalf( + "silenced members 4 and 5 must be transiently parked; got parked %v", + next.TransientlyParked, + ) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf( + "transient silence must not permanently exclude; got excluded %v", + next.ExcludedSet, + ) + } +} + +func TestNextAttempt_TransientSilenceRecoversAcrossTwoAttempts(t *testing.T) { + f := newNextAttemptFixture() + f.threshold = 5 + + // Attempt N -> N+1: members 4 and 5 are silent. They are parked (not + // excluded) and the next attempt's included set is {1,2,3} (sub-threshold), + // but the session is not failed. + f.bundleSenders = []group.MemberIndex{1, 2, 3} + attemptN1, err := computeNextAttempt( + f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}, + ) + if err != nil { + t.Fatalf("attempt N+1: transient silence must not fail, got %v", err) + } + if !memberSliceContains(attemptN1.TransientlyParked, 4) || + !memberSliceContains(attemptN1.TransientlyParked, 5) { + t.Fatalf( + "attempt N+1: members 4 and 5 must be parked; got %v", + attemptN1.TransientlyParked, + ) + } + + // Attempt N+1 -> N+2: the previously-parked members are reinstated, so the + // included set returns to all five and the session recovers without + // intervention. The bundle defaults to N+1's included set {1,2,3}, which all + // respond. + g := newNextAttemptFixture() + g.threshold = 5 + g.included = attemptN1.IncludedSet + g.excluded = attemptN1.ExcludedSet + g.parked = attemptN1.TransientlyParked + g.attemptNumber = attemptN1.AttemptNumber + attemptN2, err := computeNextAttempt( + g.prev(t), g.bundle(t), g.threshold, g.dkgGroupPublicKey, fakeVerifier{}, + ) + if err != nil { + t.Fatalf("attempt N+2: %v", err) + } + for _, m := range []group.MemberIndex{1, 2, 3, 4, 5} { + if !memberSliceContains(attemptN2.IncludedSet, m) { + t.Fatalf( + "attempt N+2 must reinstate all members; missing %d, got included %v", + m, attemptN2.IncludedSet, + ) + } + } + if len(attemptN2.TransientlyParked) != 0 { + t.Fatalf( + "attempt N+2 should have no parked members; got %v", + attemptN2.TransientlyParked, + ) + } +} + +func TestNextAttempt_ThresholdZeroDisablesInfeasibilityCheck(t *testing.T) { + f := newNextAttemptFixture() + f.threshold = 0 + // All members silent; without the infeasibility check, the next + // attempt has zero included members. This is documented as a + // test seam, not a production state. + f.bundleSenders = []group.MemberIndex{} + // We need at least one entry in the bundle for TransitionMessage + // to be valid. Add a no-op snapshot from member 1 even though + // they're "silent" by the policy's view. The policy only looks + // at bundle senders that intersect prev.IncludedSet, which all + // of them do here. So instead let's leave member 1 in the + // bundle alone and silent the rest. + f.bundleSenders = []group.MemberIndex{1} + // IncludedSet would become {1}; for threshold=0 that's still + // permitted. + _, err := computeNextAttempt(f.prev(t), f.bundle(t), 0, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("expected success with threshold=0, got %v", err) + } +} + +func TestNextAttempt_SingleObserverCountMagnitudeIsNotBlame(t *testing.T) { + f := newNextAttemptFixture() + // One observer fabricating arbitrarily large counts in every + // category is still a single accuser, far below the quorum (3): + // the accused member must be completely unaffected. This is the + // counted-blame fabrication vector the quorum policy closes. + f.overflows[5] = map[group.MemberIndex]uint{3: 1000} + f.rejects[5] = map[group.MemberIndex]uint{3: 1000} + f.conflicts[5] = map[group.MemberIndex]uint{3: 1000} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } + if memberSliceContains(next.TransientlyParked, 3) { + t.Fatal("fabricated counts must not park") + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("fabricated counts must not exclude") + } +} + +func TestNextAttempt_QuorumBoundaryForRejectAndConflict(t *testing.T) { + // n=5, t=3 => f = 2 byzantine tolerated => quorum = 3 accusers. + for _, tc := range []struct { + name string + category func(f *nextAttemptFixture) map[group.MemberIndex]map[group.MemberIndex]uint + }{ + { + name: "rejects", + category: func(f *nextAttemptFixture) map[group.MemberIndex]map[group.MemberIndex]uint { + return f.rejects + }, + }, + { + name: "conflicts", + category: func(f *nextAttemptFixture) map[group.MemberIndex]map[group.MemberIndex]uint { + return f.conflicts + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // f accusers (= 2, the max byzantine count): no action. + fixtureAtF := newNextAttemptFixture() + tc.category(fixtureAtF)[1] = map[group.MemberIndex]uint{3: 1} + tc.category(fixtureAtF)[2] = map[group.MemberIndex]uint{3: 1} + next, err := computeNextAttempt( + fixtureAtF.prev(t), + fixtureAtF.bundle(t), + fixtureAtF.threshold, + fixtureAtF.dkgGroupPublicKey, + fakeVerifier{}, + ) + if err != nil { + t.Fatalf("compute at f accusers: %v", err) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf( + "f accusers must not move sender 3; got included %v", + next.IncludedSet, + ) + } + + // f+1 accusers (= 3): permanent exclusion. + fixtureAtQuorum := newNextAttemptFixture() + tc.category(fixtureAtQuorum)[1] = map[group.MemberIndex]uint{3: 1} + tc.category(fixtureAtQuorum)[2] = map[group.MemberIndex]uint{3: 1} + tc.category(fixtureAtQuorum)[4] = map[group.MemberIndex]uint{3: 1} + next, err = computeNextAttempt( + fixtureAtQuorum.prev(t), + fixtureAtQuorum.bundle(t), + fixtureAtQuorum.threshold, + fixtureAtQuorum.dkgGroupPublicKey, + fakeVerifier{}, + ) + if err != nil { + t.Fatalf("compute at quorum: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "quorum accusers must exclude sender 3; got %v", + next.ExcludedSet, + ) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatal("excluded sender 3 must not remain included") + } + }) + } +} + +func TestNextAttempt_CrossCategoryAccusationsDoNotSum(t *testing.T) { + f := newNextAttemptFixture() + // Two reject accusers plus two different conflict accusers against + // sender 3: each category is below the quorum (3) on its own. + // Categories must be tallied independently -- four observers + // spread across categories are not an established accusation. + f.rejects[1] = map[group.MemberIndex]uint{3: 1} + f.rejects[2] = map[group.MemberIndex]uint{3: 1} + f.conflicts[4] = map[group.MemberIndex]uint{3: 1} + f.conflicts[5] = map[group.MemberIndex]uint{3: 1} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "cross-category sub-quorum claims must not exclude; got %v", + next.ExcludedSet, + ) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } +} + +func TestNextAttempt_FabricatedBlameCannotGrindHonestMembers(t *testing.T) { + // Regression for the counted-blame grinding vector: a single + // byzantine member fabricates maximal evidence against every + // honest member on every attempt. Under the quorum policy the + // honest members must never be excluded or parked, and the + // session must never become infeasible. + f := newNextAttemptFixture() + byzantine := group.MemberIndex(5) + honest := []group.MemberIndex{1, 2, 3, 4} + + prev := f.prev(t) + for attemptIndex := 0; attemptIndex < 6; attemptIndex++ { + accusations := map[group.MemberIndex]uint{} + for _, target := range honest { + accusations[target] = 1000 + } + prevHash := prev.Hash() + bundle := make([]LocalEvidenceSnapshot, 0, len(prev.IncludedSet)) + for _, sender := range prev.IncludedSet { + snap := LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, prevHash[:]...), + } + if sender == byzantine { + overflows := make([]OverflowEntry, 0, len(honest)) + rejects := make([]RejectEntry, 0, len(honest)) + conflicts := make([]ConflictEntry, 0, len(honest)) + for _, target := range honest { + overflows = append(overflows, OverflowEntry{ + Sender: target, Count: accusations[target], + }) + rejects = append(rejects, RejectEntry{ + Sender: target, + Reason: "validation_gate_rejected", + Count: accusations[target], + }) + conflicts = append(conflicts, ConflictEntry{ + Sender: target, Count: accusations[target], + }) + } + snap.Overflows = overflows + snap.Rejects = rejects + snap.Conflicts = conflicts + } + bundle = append(bundle, snap) + } + msg := &TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: 1, + Bundle: bundle, + } + + next, err := computeNextAttempt(prev, msg, f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("attempt %d: %v", attemptIndex, err) + } + for _, member := range honest { + if !memberSliceContains(next.IncludedSet, member) { + t.Fatalf( + "attempt %d: honest member %d ground out; included %v, excluded %v, parked %v", + attemptIndex, + member, + next.IncludedSet, + next.ExcludedSet, + next.TransientlyParked, + ) + } + } + prev = next + } +} + +func TestNextAttempt_NonCredibleAccusersAreIgnored(t *testing.T) { + f := newNextAttemptFixture() + // Members 1..5 included; member 6 is outside the original signer + // set and member 7 likewise. Their snapshots appear in the bundle + // (a byzantine coordinator could aggregate anything), each + // accusing sender 3 of conflicts, alongside two credible + // accusers. Total claims = 4, but credible accusers = 2 < quorum: + // no action. + f.bundleSenders = []group.MemberIndex{1, 2, 3, 4, 5, 6, 7} + f.conflicts[1] = map[group.MemberIndex]uint{3: 1} + f.conflicts[2] = map[group.MemberIndex]uint{3: 1} + f.conflicts[6] = map[group.MemberIndex]uint{3: 1} + f.conflicts[7] = map[group.MemberIndex]uint{3: 1} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "non-credible accusers must not contribute to exclusion; got %v", + next.ExcludedSet, + ) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } +} + +func TestNextAttempt_AccusationsAgainstNonOriginalMembersIgnored(t *testing.T) { + f := newNextAttemptFixture() + // Quorum-many accusers blame member 9, which is not part of the + // original signer set. The policy must not act on it: the next + // context's excluded/parked sets stay within the original set. + f.conflicts[1] = map[group.MemberIndex]uint{9: 1} + f.conflicts[2] = map[group.MemberIndex]uint{9: 1} + f.conflicts[4] = map[group.MemberIndex]uint{9: 1} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, fakeVerifier{}) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 9) { + t.Fatalf( + "non-original member must not enter excluded set; got %v", + next.ExcludedSet, + ) + } + if memberSliceContains(next.TransientlyParked, 9) { + t.Fatalf( + "non-original member must not enter parked set; got %v", + next.TransientlyParked, + ) + } +} + +func TestNextAttempt_NilBundleRejected(t *testing.T) { + c := newSignedCoordinatorForMember(0) + handle, _ := c.BeginAttempt(newTestContext(t)) + _, err := c.NextAttempt(handle, nil, 3, []byte{0x01}) + if err == nil { + t.Fatal("expected error for nil bundle") + } +} + +func TestNextAttempt_UnknownHandleRejected(t *testing.T) { + c := newSignedCoordinatorForMember(0) + bogus := AttemptHandle{id: 999} + _, err := c.NextAttempt(bogus, &TransitionMessage{}, 3, []byte{0x01}) + if !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } +} + +func TestExclusionAccuserQuorum_MatchesRFC(t *testing.T) { + // Production shape: n=100, t=51 => f = 49 => quorum 50. The 49 + // worst-case byzantine members can never fabricate a quorum; the + // 51 honest members establish any fault they all observe. + if got := ExclusionAccuserQuorum(100, 51); got != 50 { + t.Fatalf("quorum(100, 51) = %d, want 50", got) + } + // Unit-test shape used by the fixtures in this file. + if got := ExclusionAccuserQuorum(5, 3); got != 3 { + t.Fatalf("quorum(5, 3) = %d, want 3", got) + } + // Zero threshold (policy-only test seam): quorum is deliberately + // unreachable so no accusation-driven action can occur. + if got := ExclusionAccuserQuorum(5, 0); got != 6 { + t.Fatalf("quorum(5, 0) = %d, want unreachable 6", got) + } + // Degenerate threshold larger than the group: also unreachable. + if got := ExclusionAccuserQuorum(3, 5); got != 4 { + t.Fatalf("quorum(3, 5) = %d, want unreachable 4", got) + } +} + +func memberSliceContains(slice []group.MemberIndex, target group.MemberIndex) bool { + for _, m := range slice { + if m == target { + return true + } + } + return false +} + +func memberSlicesEqual(a, b []group.MemberIndex) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/frost/roast/round2_classifier.go b/pkg/frost/roast/round2_classifier.go new file mode 100644 index 0000000000..acfe879954 --- /dev/null +++ b/pkg/frost/roast/round2_classifier.go @@ -0,0 +1,244 @@ +package roast + +import ( + "fmt" + "sort" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// candidateCulpritRejectReason is the canonical Reason recorded on a RejectEntry +// minted from an engine candidate culprit whose retained share re-verifies +// invalid. It is the Go-side reason string; the engine never supplies it. +const candidateCulpritRejectReason = "invalid_signature_share" + +// ShareVerificationResult is the verdict of a FROST share re-verification. +// +// It is deliberately a three-way enum rather than a (bool, error): the boundary +// between a MEMBER-attributable failure (blame) and a not-the-member's-fault +// failure (don't blame) is security-critical, and a (bool, error) shape silently +// routes a member's own undecodable/garbage share bytes into the error channel - +// where the classifier would read them as "indeterminate" and let the cheater +// dodge blame. The enum forces the (engine-backed) implementer to categorize the +// failure boundary deliberately. +type ShareVerificationResult int + +const ( + // ShareValid: the retained share is a valid FROST signature share for the + // authoritative package. Not blamable. + ShareValid ShareVerificationResult = iota + // ShareInvalid: the share is MEMBER-attributable garbage - mathematically + // invalid against the package, OR undecodable/malformed share bytes the + // member operator-signed. Self-incriminating, hence blamable. + ShareInvalid + // ShareIndeterminate: verification could not be completed for a reason that + // is NOT the member's fault - missing verifying material, ambiguous + // key/taproot context, an undecodable AUTHORITATIVE PACKAGE, or an engine / + // FFI execution failure. Fail closed against blame. + ShareIndeterminate +) + +// Round2ShareVerifier re-verifies a retained round-2 signature share against an +// attempt's authoritative signing package using FROST share verification. +// +// The Go host has no native FROST share crypto - the collector's +// SignatureVerifier checks OPERATOR signatures over envelopes, not the FROST +// share equation - so a faithful re-verification is engine-backed. This +// interface is that seam: implementations perform PURE crypto verification only +// (no envelope or operator-signature inspection, no blame), staying within the +// engine's crypto-only boundary (frozen Q1). +// +// VerifyRetainedShare reports whether submitter's retained, operator-signed +// share envelope is a valid FROST signature share for the authoritative signing +// package envelope the collector accepted. Implementations MUST map a member's +// OWN invalid or undecodable share bytes to ShareInvalid (it is self-incriminating +// member fault), and reserve ShareIndeterminate for failures that are not the +// member's fault (see the constants). Misclassifying undecodable member bytes as +// ShareIndeterminate would let a cheater escape blame. +// +// Implementations must be safe for concurrent calls from multiple goroutines. +type Round2ShareVerifier interface { + VerifyRetainedShare( + signingPackageEnvelope []byte, + shareEnvelope []byte, + submitter group.MemberIndex, + ) ShareVerificationResult +} + +// classifierCandidate pairs a candidate culprit with the retained bytes the +// classifier needs to adjudicate it, snapshotted under the collector lock so the +// (engine-backed) re-verification runs lock-free. shareEnvelope is nil when the +// member has no ACCEPTED retained share for the attempt (divergent-only or +// absent), which the caller classifies as non-blamable. +type classifierCandidate struct { + member group.MemberIndex + shareEnvelope []byte +} + +// ClassifyCandidateCulprits turns the engine's candidate culprits for an attempt +// into this observer's reject accusations, applying the frozen Q1 boundary: the +// crypto-only engine reports who failed FROST verification against the package +// the coordinator aggregated, but only the Go host - against its OWN retained, +// operator-signed bytes - decides attributable member blame. +// +// For each candidate, against the attempt's authoritative package: +// +// - an ACCEPTED retained share that re-verifies INVALID -> a RejectEntry. The +// observer holds the member's operator-signed share and shows it invalid +// against the package it accepted: self-incriminating, independently +// checkable evidence (RFC-21 Layer B's "no bare counters" rule). +// - an ACCEPTED retained share that re-verifies VALID (yet the engine flagged +// it) -> nothing. The candidate is not self-incriminating under THIS +// observer's retained package; the cause (a substituted package, share, or +// root, or other coordinator input) is not provable from these bytes, so the +// member must not be blamed. Coordinator-directed faults are a SEPARATE +// adjudication path (Phase 7.2b-4b: package / divergent-share f+1 +// comparison), never inferred here. +// - a DIVERGENT share only (validly signed but binding a different package / +// coordinator) -> nothing. Kept NEUTRAL: a divergent share can be targeted +// coordinator equivocation, so it must not alone permanently exclude its +// member. +// - no retained share at this observer -> nothing (nothing to corroborate). +// - an INDETERMINATE re-verification -> nothing (fail closed against blame). +// +// The emitted accusations feed this observer's LocalEvidenceSnapshot.Rejects and +// hence NextAttempt's f+1 establishment gate; classification here never excludes +// anyone by itself. The result is deterministic - deduplicated and ascending by +// member, Count 1 each - so honest observers over identical retained bytes agree +// byte-for-byte. Returns ErrRound2UnknownAttempt if the attempt was never begun, +// ErrRound2NoSigningPackage if no authoritative package was recorded, and an +// error for a nil verifier. +func (c *Round2Collector) ClassifyCandidateCulprits( + attemptContextHash []byte, + candidates []group.MemberIndex, + verifier Round2ShareVerifier, +) ([]RejectEntry, error) { + if verifier == nil { + return nil, fmt.Errorf( + "roast: ClassifyCandidateCulprits requires a non-nil Round2ShareVerifier", + ) + } + + // Snapshot the retained bytes the candidates need under the lock, then + // release it before the (engine-backed, potentially slow) re-verification - + // mirroring the collector's authenticate-outside-the-lock discipline. The + // copies are collector-owned, so a concurrent PruneAttempt or record + // mutation cannot race the lock-free re-verification below. + signingPackageEnvelope, snapshot, err := c.snapshotCandidatesForClassification( + attemptContextHash, + candidates, + ) + if err != nil { + return nil, err + } + + rejects := make([]RejectEntry, 0, len(snapshot)) + for _, candidate := range snapshot { + if candidate.shareEnvelope == nil { + // No ACCEPTED share retained here: a divergent-only share (NEUTRAL), a + // member that never submitted to this observer, or an absent + // submission. Nothing self-incriminating to accuse with. + continue + } + // Blame ONLY a member-attributable invalid share. ShareValid (not + // self-incriminating under this observer's package - coordinator-directed + // faults are Phase 7.2b-4b), ShareIndeterminate (not the member's fault), + // and any future verdict fail closed against blame. + if verifier.VerifyRetainedShare( + signingPackageEnvelope, + candidate.shareEnvelope, + candidate.member, + ) != ShareInvalid { + continue + } + rejects = append(rejects, RejectEntry{ + Sender: candidate.member, + Reason: candidateCulpritRejectReason, + Count: 1, + }) + } + return rejects, nil +} + +// snapshotCandidatesForClassification copies, under the collector lock, the +// authoritative package envelope and each deduplicated, ascending candidate's +// ACCEPTED retained share envelope, so re-verification can run lock-free. +// Divergent-only and absent candidates are carried with a nil share envelope. +func (c *Round2Collector) snapshotCandidatesForClassification( + attemptContextHash []byte, + candidates []group.MemberIndex, +) ([]byte, []classifierCandidate, error) { + c.mu.Lock() + defer c.mu.Unlock() + + record, ok := c.attempts[round2AttemptKey(attemptContextHash)] + if !ok { + return nil, nil, ErrRound2UnknownAttempt + } + if record.signingPackageEnvelope == nil { + return nil, nil, ErrRound2NoSigningPackage + } + + signingPackageEnvelope := append([]byte(nil), record.signingPackageEnvelope...) + + // Deduplicate + sort so each member is adjudicated once, in a deterministic + // order, regardless of how the engine ordered or repeated the candidates. + seen := make(map[group.MemberIndex]struct{}, len(candidates)) + unique := make([]group.MemberIndex, 0, len(candidates)) + for _, member := range candidates { + if _, dup := seen[member]; dup { + continue + } + seen[member] = struct{}{} + unique = append(unique, member) + } + sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] }) + + snapshot := make([]classifierCandidate, 0, len(unique)) + for _, member := range unique { + entry := classifierCandidate{member: member} + if share, ok := record.shares[member]; ok && share != nil { + entry.shareEnvelope = append([]byte(nil), share.envelope...) + } + snapshot = append(snapshot, entry) + } + return signingPackageEnvelope, snapshot, nil +} + +// CoordinatorPackageProofs returns the coordinator-signed signing-package +// envelope(s) this observer retained for an attempt, to publish in its +// LocalEvidenceSnapshot so NextAttempt can detect coordinator equivocation +// across observers (Phase 7.2b-4b). It returns the authoritative package the +// observer accepted and, when the coordinator equivocated TO THIS observer (a +// second body-different package arrived), that conflicting package too - at most +// two envelopes (authoritative first, then the first conflicting one). +// +// These are the raw, unforgeable proof material: each envelope carries the +// coordinator's operator signature over its body and the attempt context. +// Adjudication is NextAttempt's job, NOT the collector's - NextAttempt verifies +// the coordinator signature + attempt binding, dedupes by body hash, and +// instant-excludes a coordinator that signed >= 2 distinct bodies for one +// attempt. Because that proof is unforgeable, a single honest observer suffices +// and it does NOT pass through the f+1 counted gate (which exists to bound +// fabricated bare-counter claims). The collector only surfaces retained signed +// bytes (owned copies); it never decides blame. +// +// Returns ErrRound2UnknownAttempt if the attempt was never begun, and an empty +// slice (nil error) if no authoritative package has been recorded yet. +func (c *Round2Collector) CoordinatorPackageProofs(attemptContextHash []byte) ([][]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + + record, ok := c.attempts[round2AttemptKey(attemptContextHash)] + if !ok { + return nil, ErrRound2UnknownAttempt + } + if record.signingPackageEnvelope == nil { + return nil, nil + } + proofs := [][]byte{append([]byte(nil), record.signingPackageEnvelope...)} + if record.conflictingSigningPackageEnvelope != nil { + proofs = append(proofs, append([]byte(nil), record.conflictingSigningPackageEnvelope...)) + } + return proofs, nil +} diff --git a/pkg/frost/roast/round2_classifier_test.go b/pkg/frost/roast/round2_classifier_test.go new file mode 100644 index 0000000000..cd8915862b --- /dev/null +++ b/pkg/frost/roast/round2_classifier_test.go @@ -0,0 +1,297 @@ +package roast + +import ( + "bytes" + "errors" + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// fakeShareVerifier is a configurable Round2ShareVerifier for the classifier +// tests. Members without a configured verdict default to ShareValid (no blame); +// the calls slice records every member actually re-verified, so tests can assert +// that divergent-only and absent candidates are never handed to the verifier. +type fakeShareVerifier struct { + verdicts map[group.MemberIndex]ShareVerificationResult + calls *[]group.MemberIndex +} + +func (f fakeShareVerifier) VerifyRetainedShare( + _ []byte, + _ []byte, + submitter group.MemberIndex, +) ShareVerificationResult { + if f.calls != nil { + *f.calls = append(*f.calls, submitter) + } + if r, ok := f.verdicts[submitter]; ok { + return r + } + return ShareValid +} + +// recordAcceptedShare records an authoritative-package-bound (accepted) share for +// submitter on an attempt already set up by recordTestPackage. +func recordAcceptedShare(t *testing.T, c *Round2Collector, submitter group.MemberIndex, pkgHash []byte) { + t.Helper() + if err := c.RecordShareSubmission(signedTestShareSubmission(t, submitter, pkgHash)); err != nil { + t.Fatalf("record accepted share for %d: %v", submitter, err) + } +} + +func TestClassifyCandidateCulprits_InvalidAcceptedShareEmitsReject(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := recordTestPackage(t, c, elected) + recordAcceptedShare(t, c, 3, pkgHash) + + // ShareInvalid covers both a mathematically invalid share and undecodable + // share bytes the member operator-signed: either is self-incriminating. + verifier := fakeShareVerifier{verdicts: map[group.MemberIndex]ShareVerificationResult{3: ShareInvalid}} + rejects, err := c.ClassifyCandidateCulprits(pinnedContextHash[:], []group.MemberIndex{3}, verifier) + if err != nil { + t.Fatalf("classify: %v", err) + } + want := []RejectEntry{{Sender: 3, Reason: candidateCulpritRejectReason, Count: 1}} + if !reflect.DeepEqual(rejects, want) { + t.Fatalf("want %+v, got %+v", want, rejects) + } +} + +func TestClassifyCandidateCulprits_ValidAcceptedShareEmitsNothing(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := recordTestPackage(t, c, elected) + recordAcceptedShare(t, c, 3, pkgHash) + + // The engine flagged 3, but THIS observer's retained share re-verifies valid + // against the package it accepted: not self-incriminating -> no accusation. + verifier := fakeShareVerifier{verdicts: map[group.MemberIndex]ShareVerificationResult{3: ShareValid}} + rejects, err := c.ClassifyCandidateCulprits(pinnedContextHash[:], []group.MemberIndex{3}, verifier) + if err != nil { + t.Fatalf("classify: %v", err) + } + if len(rejects) != 0 { + t.Fatalf("a valid-but-flagged candidate must not be blamed, got %+v", rejects) + } +} + +func TestClassifyCandidateCulprits_IndeterminateEmitsNothing(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := recordTestPackage(t, c, elected) + recordAcceptedShare(t, c, 3, pkgHash) + + // An indeterminate re-verification (not the member's fault) must fail closed + // against blame - distinct from ShareValid, but likewise emits nothing. + verifier := fakeShareVerifier{verdicts: map[group.MemberIndex]ShareVerificationResult{3: ShareIndeterminate}} + rejects, err := c.ClassifyCandidateCulprits(pinnedContextHash[:], []group.MemberIndex{3}, verifier) + if err != nil { + t.Fatalf("classify: %v", err) + } + if len(rejects) != 0 { + t.Fatalf("indeterminate verification must not blame, got %+v", rejects) + } +} + +func TestClassifyCandidateCulprits_DivergentShareIsNeutral(t *testing.T) { + _ = captureEquivocationEvidence(t) + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + _ = recordTestPackage(t, c, elected) + + // Member 3 has ONLY a divergent share (binds a non-authoritative package). + wrong := bytes.Repeat([]byte{0x11}, SigningPackageHashLength) + if err := c.RecordShareSubmission(signedTestShareSubmission(t, 3, wrong)); !errors.Is(err, ErrShareRetainedNotAccepted) { + t.Fatalf("setup divergent share: want ErrShareRetainedNotAccepted, got %v", err) + } + + calls := []group.MemberIndex{} + verifier := fakeShareVerifier{ + // Would blame 3 if consulted - but a divergent-only candidate must be + // skipped BEFORE re-verification. + verdicts: map[group.MemberIndex]ShareVerificationResult{3: ShareInvalid}, + calls: &calls, + } + rejects, err := c.ClassifyCandidateCulprits(pinnedContextHash[:], []group.MemberIndex{3}, verifier) + if err != nil { + t.Fatalf("classify: %v", err) + } + if len(rejects) != 0 { + t.Fatalf("a divergent-only candidate must stay neutral, got %+v", rejects) + } + if len(calls) != 0 { + t.Fatalf("the verifier must not be consulted for a divergent-only candidate, got %v", calls) + } +} + +func TestClassifyCandidateCulprits_AbsentCandidateIsNothing(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := recordTestPackage(t, c, elected) + recordAcceptedShare(t, c, 3, pkgHash) + + calls := []group.MemberIndex{} + verifier := fakeShareVerifier{verdicts: map[group.MemberIndex]ShareVerificationResult{5: ShareInvalid}, calls: &calls} + // 5 is in the included set but never submitted a share to this observer. + rejects, err := c.ClassifyCandidateCulprits(pinnedContextHash[:], []group.MemberIndex{5}, verifier) + if err != nil { + t.Fatalf("classify: %v", err) + } + if len(rejects) != 0 { + t.Fatalf("a candidate with no retained share must not be blamed, got %+v", rejects) + } + if len(calls) != 0 { + t.Fatalf("the verifier must not be consulted for an absent candidate, got %v", calls) + } +} + +func TestClassifyCandidateCulprits_MultipleSortedAndDeduplicated(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := recordTestPackage(t, c, elected) + // All three included members submitted accepted shares. + recordAcceptedShare(t, c, 3, pkgHash) + recordAcceptedShare(t, c, 5, pkgHash) + recordAcceptedShare(t, c, 7, pkgHash) + + // 3 and 7 re-verify invalid; 5 re-verifies valid (not blamed). + verifier := fakeShareVerifier{verdicts: map[group.MemberIndex]ShareVerificationResult{ + 3: ShareInvalid, + 5: ShareValid, + 7: ShareInvalid, + }} + // Candidates arrive unsorted and duplicated. + rejects, err := c.ClassifyCandidateCulprits( + pinnedContextHash[:], + []group.MemberIndex{7, 5, 3, 7, 3}, + verifier, + ) + if err != nil { + t.Fatalf("classify: %v", err) + } + want := []RejectEntry{ + {Sender: 3, Reason: candidateCulpritRejectReason, Count: 1}, + {Sender: 7, Reason: candidateCulpritRejectReason, Count: 1}, + } + if !reflect.DeepEqual(rejects, want) { + t.Fatalf("want deduplicated, ascending rejects %+v, got %+v", want, rejects) + } +} + +func TestClassifyCandidateCulprits_Errors(t *testing.T) { + elected := group.MemberIndex(testShareCoordinatorID) + + t.Run("nil verifier", func(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + _ = recordTestPackage(t, c, elected) + if _, err := c.ClassifyCandidateCulprits(pinnedContextHash[:], []group.MemberIndex{3}, nil); err == nil { + t.Fatal("a nil verifier must be rejected, not panic") + } + }) + + t.Run("unknown attempt", func(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + _, err := c.ClassifyCandidateCulprits(pinnedContextHash[:], []group.MemberIndex{3}, fakeShareVerifier{}) + if !errors.Is(err, ErrRound2UnknownAttempt) { + t.Fatalf("want ErrRound2UnknownAttempt, got %v", err) + } + }) + + t.Run("no signing package recorded", func(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + _, err := c.ClassifyCandidateCulprits(pinnedContextHash[:], []group.MemberIndex{3}, fakeShareVerifier{}) + if !errors.Is(err, ErrRound2NoSigningPackage) { + t.Fatalf("want ErrRound2NoSigningPackage, got %v", err) + } + }) + + t.Run("no candidates yields no rejects", func(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + _ = recordTestPackage(t, c, elected) + rejects, err := c.ClassifyCandidateCulprits(pinnedContextHash[:], nil, fakeShareVerifier{}) + if err != nil { + t.Fatalf("classify: %v", err) + } + if len(rejects) != 0 { + t.Fatalf("no candidates must yield no rejects, got %+v", rejects) + } + }) +} + +func TestCoordinatorPackageProofs_SurfacesAuthoritativeAndConflicting(t *testing.T) { + _ = captureEquivocationEvidence(t) + c := NewRound2Collector(fakeVerifier{}) + const elected = group.MemberIndex(3) + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + + // No package recorded yet -> no proofs. + proofs, err := c.CoordinatorPackageProofs(pinnedContextHash[:]) + if err != nil { + t.Fatalf("proofs (pre-package): %v", err) + } + if len(proofs) != 0 { + t.Fatalf("no package yet, want no proofs, got %d", len(proofs)) + } + + // One authoritative package -> exactly one proof (the accepted package the + // observer publishes for cross-observer comparison). + if err := c.RecordSigningPackage(signedTestSigningPackage(t, elected, nil)); err != nil { + t.Fatalf("record authoritative package: %v", err) + } + proofs, err = c.CoordinatorPackageProofs(pinnedContextHash[:]) + if err != nil { + t.Fatalf("proofs (one package): %v", err) + } + if len(proofs) != 1 || len(proofs[0]) == 0 { + t.Fatalf("want exactly one non-empty authoritative proof, got %d", len(proofs)) + } + authoritative := append([]byte(nil), proofs[0]...) + + // A second body-different authenticated package = coordinator equivocation + // -> two proofs (authoritative first, then the conflicting one), the + // unforgeable pair NextAttempt instant-excludes on. + scriptRoot := bytes.Repeat([]byte{0xab}, TaprootMerkleRootLength) + if err := c.RecordSigningPackage(signedTestSigningPackage(t, elected, scriptRoot)); !errors.Is(err, ErrSigningPackageConflict) { + t.Fatalf("want ErrSigningPackageConflict, got %v", err) + } + proofs, err = c.CoordinatorPackageProofs(pinnedContextHash[:]) + if err != nil { + t.Fatalf("proofs (conflict): %v", err) + } + if len(proofs) != 2 { + t.Fatalf("want two proofs (authoritative + conflicting), got %d", len(proofs)) + } + if !bytes.Equal(proofs[0], authoritative) { + t.Fatal("the authoritative proof must stay first and unchanged") + } + if bytes.Equal(proofs[1], authoritative) || len(proofs[1]) == 0 { + t.Fatal("the conflicting proof must be a distinct, non-empty envelope") + } + conflicting := append([]byte(nil), proofs[1]...) + + // A third body-different package stays capped at two, keeping the FIRST + // conflicting envelope (idempotent retention). + otherRoot := bytes.Repeat([]byte{0xcd}, TaprootMerkleRootLength) + if err := c.RecordSigningPackage(signedTestSigningPackage(t, elected, otherRoot)); !errors.Is(err, ErrSigningPackageConflict) { + t.Fatalf("third package: want ErrSigningPackageConflict, got %v", err) + } + proofs, _ = c.CoordinatorPackageProofs(pinnedContextHash[:]) + if len(proofs) != 2 || !bytes.Equal(proofs[0], authoritative) || !bytes.Equal(proofs[1], conflicting) { + t.Fatalf("must stay capped at two with the first (authoritative, conflicting) pair, got %d", len(proofs)) + } +} + +func TestCoordinatorPackageProofs_UnknownAttempt(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + if _, err := c.CoordinatorPackageProofs(pinnedContextHash[:]); !errors.Is(err, ErrRound2UnknownAttempt) { + t.Fatalf("want ErrRound2UnknownAttempt, got %v", err) + } +} diff --git a/pkg/frost/roast/round2_collector.go b/pkg/frost/roast/round2_collector.go new file mode 100644 index 0000000000..6c0f38a621 --- /dev/null +++ b/pkg/frost/roast/round2_collector.go @@ -0,0 +1,503 @@ +package roast + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sync" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// EquivocationKindSigningPackageConflict: the elected coordinator distributed +// two DIFFERENT signed signing packages for the same attempt. Two +// coordinator-signed envelopes with the same attempt context but different +// bytes are self-incriminating proof of coordinator equivocation. +const EquivocationKindSigningPackageConflict = "signing_package_conflict" + +// EquivocationKindShareConflict: a submitter returned two DIFFERENT signed shares +// for the SAME authenticated instruction. Authentication pins coordinator_id, +// attempt, and the signing-package hash, so the differing field is the FROST +// signature_share itself - self-incriminating member double-signing. (A +// submission referencing a DIFFERENT package or coordinator is rejected at +// authentication, not flagged here; detecting that as targeted coordinator +// equivocation needs cross-package retention plus the f+1 quorum compare and is +// deferred to Phase 7.2b-4.) +const EquivocationKindShareConflict = "share_conflict" + +// EquivocationKindDivergentShare: an included member submitted a validly-signed, +// attempt-bound share that does NOT bind the attempt's authoritative package +// (different signing-package hash or coordinator). It is retained as evidence of +// possible TARGETED coordinator equivocation (a package distributed to only some +// members) - the collector preserves the bytes but does NOT attribute fault +// locally; coordinator-vs-member classification is the f+1 quorum's job +// (Phase 7.2b-4). The evidence carries the divergent share (ConflictingEnvelope) +// and the attempt's authoritative signing-package envelope (ExistingEnvelope). +const EquivocationKindDivergentShare = "divergent_share" + +// ErrRound2UnknownAttempt is returned by Round2Collector when no binding exists +// for an attempt (BeginAttempt was not called, or the attempt was pruned). +var ErrRound2UnknownAttempt = errors.New( + "roast: round2 collector has no binding for this attempt", +) + +// ErrRound2AttemptBindingConflict is returned by BeginAttempt when called again +// for the same attempt with a different elected coordinator or included set. +var ErrRound2AttemptBindingConflict = errors.New( + "roast: round2 attempt binding conflicts with the existing one", +) + +// ErrSigningPackageConflict is returned by RecordSigningPackage when a second +// authenticated package with a DIFFERENT signed body is recorded for an attempt +// that already has one - coordinator equivocation. The first package is retained +// (first-write-wins) and EquivocationEvidence is emitted. +var ErrSigningPackageConflict = errors.New( + "roast: a different signing package was already recorded for this attempt (coordinator equivocation)", +) + +// ErrRound2NoSigningPackage is returned by RecordShareSubmission when no signing +// package has been recorded for the attempt yet - a share cannot be bound or +// authenticated without the package it answers. +var ErrRound2NoSigningPackage = errors.New( + "roast: no signing package recorded for this attempt yet", +) + +// ErrRound2SubmitterNotIncluded is returned by RecordShareSubmission when the +// submitter is not in the attempt's included set. +var ErrRound2SubmitterNotIncluded = errors.New( + "roast: share submitter is not in the attempt's included set", +) + +// ErrShareConflict is returned by RecordShareSubmission when a submitter records +// a second share with a DIFFERENT signed body for an attempt - member +// equivocation. The first share is retained (first-write-wins) and +// EquivocationEvidence is emitted. +var ErrShareConflict = errors.New( + "roast: a different share was already recorded by this submitter for this attempt (member equivocation)", +) + +// ErrShareRetainedNotAccepted is returned by RecordShareSubmission when a share +// is genuinely submitter-signed for the attempt but does NOT bind the +// authoritative package/coordinator. It is RETAINED as divergent evidence (for +// Phase 7.2b-4) but is NOT an accepted aggregation share - the caller must not +// count it toward the signing threshold. +var ErrShareRetainedNotAccepted = errors.New( + "roast: share retained as divergent evidence but not accepted (does not bind the authoritative package)", +) + +// Round2Collector is the Go-side blame-input layer (RFC-21 Phase 7.2b). It +// retains the exact round-2 bytes seen for an attempt - the elected +// coordinator's signed signing package and (a later increment) members' signed +// share submissions - and flags equivocation by emitting EquivocationEvidence. +// The retained bytes are the input to f+1-quorum blame adjudication +// (Phase 7.2b-4); the Rust signing engine stays crypto-only. +// +// It is deliberately separate from the evidence/transition Coordinator: round-2 +// signing is a distinct sub-protocol, and the blame layer must own the complete +// retained-byte set (packages and shares) that adjudication compares. It is +// keyed by the attempt context hash and does NOT fork coordinator selection - +// the caller supplies the elected coordinator (resolved via the same +// SelectCoordinator the Coordinator uses). +// +// Authentication runs outside the lock; equivocation evidence is emitted after +// the lock is released, mirroring the hardened snapshot path. Safe for +// concurrent use. Callers MUST PruneAttempt once an attempt concludes; the +// collector does not self-expire (it has no view of attempt lifecycle). +type Round2Collector struct { + mu sync.Mutex + verifier SignatureVerifier + attempts map[string]*round2Record +} + +type round2Record struct { + attemptContextHash []byte + electedCoordinator group.MemberIndex + includedSet map[group.MemberIndex]struct{} + // signingPackageEnvelope is a collector-OWNED copy of the attempt's + // authoritative package's on-wire envelope (the first authenticated one); + // nil until one is recorded. signingPackageBodyHash is its BodyHash - the + // value a share submission binds to, and the identity used to detect + // coordinator equivocation. The identity is the BODY, not the envelope: the + // coordinator signature does not cover the outer envelope, so an unsigned + // re-encoding of the same (body, signature) is not equivocation. + signingPackageEnvelope []byte + signingPackageBodyHash [sha256.Size]byte + // shares retains each submitter's ACCEPTED share (binds the authoritative + // package + elected coordinator): its BodyHash (the member-equivocation + // identity) plus an owned copy of its envelope (evidence). + shares map[group.MemberIndex]*round2ShareRecord + // divergentShares retains each submitter's first validly-signed but + // NON-authoritative-package-bound share - blame evidence of possible targeted + // coordinator equivocation, not an aggregation input. + divergentShares map[group.MemberIndex]*round2ShareRecord + // conflictingSigningPackageEnvelope retains the FIRST body-different signing + // package the elected coordinator distributed for this attempt (the + // authoritative one is signingPackageEnvelope). Non-nil = the coordinator + // equivocated: the observer holds two distinct, individually authenticated + // coordinator-signed packages - unforgeable, self-incriminating proof. + // Surfaced (with the authoritative package) as raw proof material by + // CoordinatorPackageProofs for NextAttempt's proof-carrying coordinator + // exclusion - never as a bare f+1 ConflictEntry. + conflictingSigningPackageEnvelope []byte +} + +// round2ShareRecord is a collector-owned record of one submitter's retained +// share: the body hash (identity, stable across envelope re-encoding) and an +// owned copy of the on-wire envelope (for equivocation evidence and blame). +type round2ShareRecord struct { + bodyHash [sha256.Size]byte + envelope []byte +} + +// NewRound2Collector returns a collector that authenticates retained bytes with +// the given verifier. +func NewRound2Collector(verifier SignatureVerifier) *Round2Collector { + return &Round2Collector{ + verifier: verifier, + attempts: map[string]*round2Record{}, + } +} + +func round2AttemptKey(attemptContextHash []byte) string { + return hex.EncodeToString(attemptContextHash) +} + +// BeginAttempt establishes the round-2 binding for an attempt: its elected +// coordinator and included set, resolved by the caller from the attempt context +// (the collector does not re-run coordinator selection). Idempotent for an +// identical binding; returns ErrRound2AttemptBindingConflict if called again +// with a different elected coordinator or included set. +func (c *Round2Collector) BeginAttempt( + attemptContextHash []byte, + electedCoordinator group.MemberIndex, + includedSet []group.MemberIndex, +) error { + if len(attemptContextHash) != attempt.MessageDigestLength { + return fmt.Errorf( + "round2: attempt context hash length [%d], expected [%d]", + len(attemptContextHash), + attempt.MessageDigestLength, + ) + } + if electedCoordinator == 0 { + return errors.New("round2: elected coordinator is zero") + } + included := make(map[group.MemberIndex]struct{}, len(includedSet)) + for _, m := range includedSet { + included[m] = struct{}{} + } + + c.mu.Lock() + defer c.mu.Unlock() + key := round2AttemptKey(attemptContextHash) + if existing, ok := c.attempts[key]; ok { + if existing.electedCoordinator != electedCoordinator || + !sameMemberSet(existing.includedSet, included) { + return ErrRound2AttemptBindingConflict + } + return nil // idempotent re-begin + } + c.attempts[key] = &round2Record{ + attemptContextHash: append([]byte(nil), attemptContextHash...), + electedCoordinator: electedCoordinator, + includedSet: included, + shares: map[group.MemberIndex]*round2ShareRecord{}, + divergentShares: map[group.MemberIndex]*round2ShareRecord{}, + } + return nil +} + +// RecordSigningPackage authenticates a signed signing package against the +// attempt's binding (elected coordinator + attempt context) and retains it. The +// first authenticated package becomes the attempt's authoritative package - its +// BodyHash binds share submissions. Re-recording the same signed body is +// idempotent, including an unsigned envelope re-encoding of it. A second package +// with a DIFFERENT signed body for the same attempt is coordinator equivocation: +// the first is retained (first-write-wins), both envelopes are emitted as +// EquivocationEvidence, and ErrSigningPackageConflict is returned. A package +// that fails authentication is rejected without retention. Returns +// ErrRound2UnknownAttempt if BeginAttempt was not called. +func (c *Round2Collector) RecordSigningPackage(pkg *SigningPackage) error { + if pkg == nil { + return errors.New("round2: nil signing package") + } + key := round2AttemptKey(pkg.AttemptContextHash) + + c.mu.Lock() + record, ok := c.attempts[key] + if !ok { + c.mu.Unlock() + return ErrRound2UnknownAttempt + } + elected := record.electedCoordinator + attemptHash := append([]byte(nil), record.attemptContextHash...) + c.mu.Unlock() + + // Authenticate outside the lock (verification is the expensive step). A + // failed package is forgeable noise - reject without retaining. + if err := AuthenticateSigningPackage(c.verifier, pkg, elected, attemptHash); err != nil { + return err + } + // Identity is the BODY hash, not the envelope: the coordinator signature does + // not cover the outer envelope, so an unsigned re-encoding of the same + // (body, signature) must NOT count as a different package. + bodyHash, err := pkg.BodyHash() + if err != nil { + return err + } + // Own a defensive copy of the envelope: the caller may reuse pkg (e.g. a + // receive loop calling Unmarshal for the next message), which would mutate + // the retained bytes out from under us. + envelope, err := pkg.Marshal() + if err != nil { + return err + } + ownedEnvelope := append([]byte(nil), envelope...) + + var evidence *EquivocationEvidence + c.mu.Lock() + record, ok = c.attempts[key] + if !ok { + c.mu.Unlock() + return ErrRound2UnknownAttempt // pruned concurrently + } + if record.electedCoordinator != elected { + // Binding changed under us (prune + re-begin): the authentication we ran + // no longer matches this record. + c.mu.Unlock() + return ErrRound2UnknownAttempt + } + switch { + case record.signingPackageEnvelope == nil: + record.signingPackageEnvelope = ownedEnvelope + record.signingPackageBodyHash = bodyHash + case record.signingPackageBodyHash == bodyHash: + // Idempotent: the same signed body re-recorded (possibly re-encoded). + default: + // A different signed body for the same attempt: coordinator equivocation. + // Keep the first authoritative package; retain the first conflicting one + // (idempotent) as self-incriminating proof, and emit both. + if record.conflictingSigningPackageEnvelope == nil { + record.conflictingSigningPackageEnvelope = ownedEnvelope + } + evidence = &EquivocationEvidence{ + Kind: EquivocationKindSigningPackageConflict, + AttemptContextHash: append([]byte(nil), record.attemptContextHash...), + Sender: elected, + ExistingEnvelope: append([]byte(nil), record.signingPackageEnvelope...), + ConflictingEnvelope: signingPackageEnvelopeForEvidence(pkg), + } + } + c.mu.Unlock() + + if evidence != nil { + emitEquivocationEvidence(*evidence) + return ErrSigningPackageConflict + } + return nil +} + +// RecordShareSubmission verifies and retains a member's signed share submission. +// The attempt must already have a recorded signing package +// (ErrRound2NoSigningPackage) and the submitter must be in the included set +// (ErrRound2SubmitterNotIncluded, checked cheaply before signature verification). +// The submitter signature is verified over the attempt-bound body; a share not +// genuinely submitter-signed for this attempt is rejected without retention. +// +// A verified share is classified: +// - ACCEPTED if it binds the elected coordinator AND the authoritative signing +// package (its BodyHash): retained as an aggregation share, first-write-wins. +// A second ACCEPTED share with a different body from the same submitter is +// member double-signing -> EquivocationKindShareConflict, ErrShareConflict. +// - DIVERGENT otherwise (validly signed for the attempt but bound to a +// different package/coordinator): retained SEPARATELY as evidence of possible +// targeted coordinator equivocation -> EquivocationKindDivergentShare, +// ErrShareRetainedNotAccepted (retained, NOT counted toward the threshold). +// Fault is not attributed here; the f+1 quorum decides in Phase 7.2b-4. +// +// Re-recording the same body (accepted or divergent) is idempotent, including an +// unsigned envelope re-encoding. +func (c *Round2Collector) RecordShareSubmission(sub *ShareSubmission) error { + if sub == nil { + return errors.New("round2: nil share submission") + } + // Validate up front so SubmitterID() is bounded (uint32 -> MemberIndex) for + // the membership pre-gate below. Membership is a cheap gate before the + // expensive signature verify: it rejects validly-signed-but-not-included + // junk without burning CPU. It is safe to gate here because the attempt + // context hash cryptographically commits to the included set, so the set for + // a given attempt key cannot change under us. + if err := sub.Validate(); err != nil { + return fmt.Errorf("share submission failed structural validation: %w", err) + } + key := round2AttemptKey(sub.AttemptContextHash) + submitter := sub.SubmitterID() + + c.mu.Lock() + record, ok := c.attempts[key] + if !ok { + c.mu.Unlock() + return ErrRound2UnknownAttempt + } + if record.signingPackageEnvelope == nil { + c.mu.Unlock() + return ErrRound2NoSigningPackage + } + if _, included := record.includedSet[submitter]; !included { + c.mu.Unlock() + return ErrRound2SubmitterNotIncluded + } + elected := record.electedCoordinator + attemptHash := append([]byte(nil), record.attemptContextHash...) + pkgBodyHash := record.signingPackageBodyHash + c.mu.Unlock() + + // Verify the submitter signature OUTSIDE the lock (the expensive step). This + // is the WEAKER check: it does not require the share to bind the authoritative + // package or elected coordinator, because a submitter-signed share that + // diverges from the authoritative package is retained as evidence (see + // EquivocationKindDivergentShare), never dropped. + if err := verifyShareSubmissionForAttempt(c.verifier, sub, attemptHash); err != nil { + return err + } + // Identity is the share BODY hash, not the envelope - an unsigned re-encoding + // of the same signed body must NOT count as a conflicting share. + bodyHash, err := sub.BodyHash() + if err != nil { + return err + } + envelope, err := sub.Marshal() + if err != nil { + return err + } + ownedEnvelope := append([]byte(nil), envelope...) + // Accepted = binds the elected coordinator AND the authoritative package + // (eligible for aggregation). Otherwise the share is divergent: retained as + // targeted-equivocation evidence, not an aggregation input. + accepted := sub.CoordinatorID() == elected && + bytes.Equal(sub.SigningPackageHash, pkgBodyHash[:]) + + var ( + evidence *EquivocationEvidence + result error + ) + c.mu.Lock() + record, ok = c.attempts[key] + if !ok { + c.mu.Unlock() + return ErrRound2UnknownAttempt + } + if record.electedCoordinator != elected || record.signingPackageBodyHash != pkgBodyHash { + // Binding or authoritative package changed under us (prune + re-begin). + c.mu.Unlock() + return ErrRound2UnknownAttempt + } + if accepted { + switch existing, present := record.shares[submitter]; { + case !present: + record.shares[submitter] = &round2ShareRecord{ + bodyHash: bodyHash, + envelope: ownedEnvelope, + } + case existing.bodyHash == bodyHash: + // Idempotent: the same accepted share re-recorded (possibly re-encoded). + default: + // A different accepted share body from the same submitter: member + // double-signing the same instruction. Keep the first; emit both. + evidence = &EquivocationEvidence{ + Kind: EquivocationKindShareConflict, + AttemptContextHash: append([]byte(nil), record.attemptContextHash...), + Sender: submitter, + ExistingEnvelope: append([]byte(nil), existing.envelope...), + ConflictingEnvelope: ownedEnvelope, + } + result = ErrShareConflict + } + } else { + // Divergent: retain as evidence (first per submitter) and flag it WITHOUT + // attributing fault - the f+1 quorum (7.2b-4) decides coordinator vs + // member. Always reported as retained-not-accepted so the caller does not + // count it toward the threshold. + result = ErrShareRetainedNotAccepted + switch existing, present := record.divergentShares[submitter]; { + case !present: + record.divergentShares[submitter] = &round2ShareRecord{ + bodyHash: bodyHash, + envelope: ownedEnvelope, + } + evidence = divergentShareEvidence(record, submitter, ownedEnvelope) + case existing.bodyHash == bodyHash: + // Idempotent: the same divergent share re-recorded. + default: + // A second, different divergent share from the same submitter: re-flag + // (keep the first retained). + evidence = divergentShareEvidence(record, submitter, ownedEnvelope) + } + } + c.mu.Unlock() + + if evidence != nil { + emitEquivocationEvidence(*evidence) + } + return result +} + +// divergentShareEvidence builds EquivocationKindDivergentShare evidence: the +// divergent share envelope plus the attempt's authoritative signing-package +// envelope for context. The caller holds the lock. +func divergentShareEvidence( + record *round2Record, + submitter group.MemberIndex, + divergentEnvelope []byte, +) *EquivocationEvidence { + return &EquivocationEvidence{ + Kind: EquivocationKindDivergentShare, + AttemptContextHash: append([]byte(nil), record.attemptContextHash...), + Sender: submitter, + ExistingEnvelope: append([]byte(nil), record.signingPackageEnvelope...), + ConflictingEnvelope: append([]byte(nil), divergentEnvelope...), + } +} + +// PruneAttempt drops all retained round-2 state for an attempt. Callers invoke +// it when an attempt concludes (success or abandonment) to bound retention. +func (c *Round2Collector) PruneAttempt(attemptContextHash []byte) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.attempts, round2AttemptKey(attemptContextHash)) +} + +func sameMemberSet(a, b map[group.MemberIndex]struct{}) bool { + if len(a) != len(b) { + return false + } + for m := range a { + if _, ok := b[m]; !ok { + return false + } + } + return true +} + +// signingPackageEnvelopeForEvidence encodes a package's signed envelope for +// evidence retention, tolerating encode failures (nil result) so the detection +// path never degrades. Mirrors snapshotEnvelopeForEvidence. +func signingPackageEnvelopeForEvidence(pkg *SigningPackage) []byte { + if pkg == nil { + return nil + } + envelope, err := pkg.Marshal() + if err != nil { + equivocationLogger.Warnf( + "could not encode signing package envelope for evidence retention: [%v]", + err, + ) + return nil + } + // Defensive copy: Marshal returns the package's internal cache, but evidence + // bytes are handed to an external observer that may retain/mutate them. + return append([]byte(nil), envelope...) +} diff --git a/pkg/frost/roast/round2_collector_test.go b/pkg/frost/roast/round2_collector_test.go new file mode 100644 index 0000000000..f56c196a4b --- /dev/null +++ b/pkg/frost/roast/round2_collector_test.go @@ -0,0 +1,407 @@ +package roast + +import ( + "bytes" + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func testIncludedSet() []group.MemberIndex { + return []group.MemberIndex{3, 5, 7} +} + +func TestRound2Collector_BeginAttempt(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + const elected = group.MemberIndex(3) + + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + // Idempotent for an identical binding. + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("re-begin (identical) must be idempotent: %v", err) + } + // Conflicting elected coordinator. + if err := c.BeginAttempt(pinnedContextHash[:], elected+1, testIncludedSet()); !errors.Is(err, ErrRound2AttemptBindingConflict) { + t.Fatalf("want ErrRound2AttemptBindingConflict for a different coordinator, got %v", err) + } + // Conflicting included set. + if err := c.BeginAttempt(pinnedContextHash[:], elected, []group.MemberIndex{3, 5}); !errors.Is(err, ErrRound2AttemptBindingConflict) { + t.Fatalf("want ErrRound2AttemptBindingConflict for a different included set, got %v", err) + } + // Malformed attempt hash + zero coordinator are rejected. + if err := c.BeginAttempt([]byte{1, 2, 3}, elected, testIncludedSet()); err == nil { + t.Fatal("a short attempt context hash must be rejected") + } + if err := c.BeginAttempt(bytes.Repeat([]byte{0x01}, 32), 0, testIncludedSet()); err == nil { + t.Fatal("a zero elected coordinator must be rejected") + } +} + +func TestRound2Collector_RecordSigningPackage_RetainsAuthenticated(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + const elected = group.MemberIndex(3) + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + + if err := c.RecordSigningPackage(signedTestSigningPackage(t, elected, nil)); err != nil { + t.Fatalf("record authenticated package: %v", err) + } + // An identical package (deterministic fakeSigner) re-records idempotently. + if err := c.RecordSigningPackage(signedTestSigningPackage(t, elected, nil)); err != nil { + t.Fatalf("re-record identical package must be idempotent: %v", err) + } +} + +func TestRound2Collector_RecordSigningPackage_Rejections(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + const elected = group.MemberIndex(3) + + // No binding yet. + if err := c.RecordSigningPackage(signedTestSigningPackage(t, elected, nil)); !errors.Is(err, ErrRound2UnknownAttempt) { + t.Fatalf("want ErrRound2UnknownAttempt before BeginAttempt, got %v", err) + } + + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + // A package from a non-elected coordinator fails authentication and is not + // retained. + if err := c.RecordSigningPackage(signedTestSigningPackage(t, elected+6, nil)); !errors.Is(err, ErrSigningPackageWrongCoordinator) { + t.Fatalf("want ErrSigningPackageWrongCoordinator, got %v", err) + } +} + +func TestRound2Collector_RecordSigningPackage_DetectsCoordinatorEquivocation(t *testing.T) { + captured := captureEquivocationEvidence(t) + c := NewRound2Collector(fakeVerifier{}) + const elected = group.MemberIndex(3) + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + + if err := c.RecordSigningPackage(signedTestSigningPackage(t, elected, nil)); err != nil { + t.Fatalf("record first package: %v", err) + } + // A second, DIFFERENT authenticated package (script-path root) for the same + // attempt is coordinator equivocation. + scriptRoot := bytes.Repeat([]byte{0xab}, TaprootMerkleRootLength) + err := c.RecordSigningPackage(signedTestSigningPackage(t, elected, scriptRoot)) + if !errors.Is(err, ErrSigningPackageConflict) { + t.Fatalf("want ErrSigningPackageConflict, got %v", err) + } + + if len(*captured) != 1 { + t.Fatalf("expected 1 equivocation event, got %d", len(*captured)) + } + ev := (*captured)[0] + if ev.Kind != EquivocationKindSigningPackageConflict { + t.Fatalf("want kind %q, got %q", EquivocationKindSigningPackageConflict, ev.Kind) + } + if ev.Sender != elected { + t.Fatalf("want sender %d (elected coordinator), got %d", elected, ev.Sender) + } + if len(ev.ExistingEnvelope) == 0 || len(ev.ConflictingEnvelope) == 0 { + t.Fatal("both the existing and conflicting envelopes must be retained as evidence") + } + if bytes.Equal(ev.ExistingEnvelope, ev.ConflictingEnvelope) { + t.Fatal("the conflicting envelope must differ from the existing one") + } +} + +func TestRound2Collector_PruneAttempt(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + const elected = group.MemberIndex(3) + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + c.PruneAttempt(pinnedContextHash[:]) + // After pruning, the binding is gone: recording requires BeginAttempt again. + if err := c.RecordSigningPackage(signedTestSigningPackage(t, elected, nil)); !errors.Is(err, ErrRound2UnknownAttempt) { + t.Fatalf("want ErrRound2UnknownAttempt after prune, got %v", err) + } +} + +func TestRound2Collector_RecordSigningPackage_EnvelopeReEncodingIsNotEquivocation(t *testing.T) { + captured := captureEquivocationEvidence(t) + c := NewRound2Collector(fakeVerifier{}) + const elected = group.MemberIndex(3) + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + + pkg := signedTestSigningPackage(t, elected, nil) + if err := c.RecordSigningPackage(pkg); err != nil { + t.Fatalf("record: %v", err) + } + + // Re-wrap the SAME (body, signature) in a different valid envelope encoding + // (reversed field order). The coordinator signature does not cover the outer + // envelope, so this is the same signed body - not equivocation. + body, _ := pkg.bodyBytes() + var reEncoded []byte + reEncoded = append(reEncoded, 0x12, byte(len(pkg.CoordinatorSignature))) + reEncoded = append(reEncoded, pkg.CoordinatorSignature...) + reEncoded = append(reEncoded, 0x0a, byte(len(body))) + reEncoded = append(reEncoded, body...) + var reDecoded SigningPackage + if err := reDecoded.Unmarshal(reEncoded); err != nil { + t.Fatalf("unmarshal re-encoded: %v", err) + } + + if err := c.RecordSigningPackage(&reDecoded); err != nil { + t.Fatalf("a re-encoded same-body package must record idempotently, got %v", err) + } + if len(*captured) != 0 { + t.Fatalf("envelope re-encoding must NOT emit equivocation evidence, got %d events", len(*captured)) + } +} + +func TestRound2Collector_RecordSigningPackage_RetainsOwnedCopy(t *testing.T) { + captured := captureEquivocationEvidence(t) + c := NewRound2Collector(fakeVerifier{}) + const elected = group.MemberIndex(3) + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + + // Record pkgA, capturing its original envelope bytes. + pkgA := signedTestSigningPackage(t, elected, nil) + origWire, err := pkgA.Marshal() + if err != nil { + t.Fatalf("marshal pkgA: %v", err) + } + origWire = append([]byte(nil), origWire...) + if err := c.RecordSigningPackage(pkgA); err != nil { + t.Fatalf("record pkgA: %v", err) + } + + // Mutate the caller's pkgA object (as a struct-reusing receive loop would, + // via Unmarshal). The collector must have retained its own copy. + pkgB := signedTestSigningPackage(t, elected, bytes.Repeat([]byte{0xab}, TaprootMerkleRootLength)) + bWire, _ := pkgB.Marshal() + if err := pkgA.Unmarshal(append([]byte(nil), bWire...)); err != nil { + t.Fatalf("mutate pkgA: %v", err) + } + + // Recording pkgB (different body) is equivocation; the evidence must carry + // pkgA's ORIGINAL envelope, not the mutated bytes. + if err := c.RecordSigningPackage(pkgB); !errors.Is(err, ErrSigningPackageConflict) { + t.Fatalf("want ErrSigningPackageConflict, got %v", err) + } + if len(*captured) != 1 { + t.Fatalf("expected 1 equivocation event, got %d", len(*captured)) + } + if !bytes.Equal((*captured)[0].ExistingEnvelope, origWire) { + t.Fatal("retained existing envelope must be the collector's own copy, unaffected by caller mutation") + } +} + +func TestRound2_NilInputsAreRejectedNotPanicked(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + if err := c.RecordSigningPackage(nil); err == nil { + t.Fatal("RecordSigningPackage(nil) must return an error, not panic") + } + // The auth entry points validate first, so a nil package/share is rejected + // via the Validate nil-receiver guard rather than panicking. + if err := AuthenticateSigningPackage(fakeVerifier{}, nil, 3, pinnedContextHash[:]); err == nil { + t.Fatal("AuthenticateSigningPackage(nil) must return an error") + } + if err := AuthenticateShareSubmission(fakeVerifier{}, nil, 3, pinnedContextHash[:], testSigningPackageHash()); err == nil { + t.Fatal("AuthenticateShareSubmission(nil) must return an error") + } + + // Marshal/Unmarshal on a nil receiver must error, not panic - matching the + // Validate/SignableBytes/BodyHash contract. + if _, err := (*SigningPackage)(nil).Marshal(); err == nil { + t.Fatal("SigningPackage.Marshal on a nil receiver must return an error") + } + if err := (*SigningPackage)(nil).Unmarshal([]byte{0x01}); err == nil { + t.Fatal("SigningPackage.Unmarshal into a nil receiver must return an error") + } + if _, err := (*ShareSubmission)(nil).Marshal(); err == nil { + t.Fatal("ShareSubmission.Marshal on a nil receiver must return an error") + } + if err := (*ShareSubmission)(nil).Unmarshal([]byte{0x01}); err == nil { + t.Fatal("ShareSubmission.Unmarshal into a nil receiver must return an error") + } +} + +// recordTestPackage begins an attempt, records a coordinator-signed package, and +// returns the package's BodyHash (the value shares must bind to). +func recordTestPackage(t *testing.T, c *Round2Collector, elected group.MemberIndex) []byte { + t.Helper() + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + pkg := signedTestSigningPackage(t, elected, nil) + if err := c.RecordSigningPackage(pkg); err != nil { + t.Fatalf("record package: %v", err) + } + h, err := pkg.BodyHash() + if err != nil { + t.Fatalf("body hash: %v", err) + } + return h[:] +} + +func TestRound2Collector_RecordShareSubmission_RetainsAuthenticated(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := recordTestPackage(t, c, elected) + + if err := c.RecordShareSubmission(signedTestShareSubmission(t, 3, pkgHash)); err != nil { + t.Fatalf("record share: %v", err) + } + // An identical share (deterministic fakeSigner) re-records idempotently. + if err := c.RecordShareSubmission(signedTestShareSubmission(t, 3, pkgHash)); err != nil { + t.Fatalf("re-record identical share must be idempotent: %v", err) + } +} + +func TestRound2Collector_RecordShareSubmission_Rejections(t *testing.T) { + elected := group.MemberIndex(testShareCoordinatorID) + + t.Run("nil is rejected", func(t *testing.T) { + if err := NewRound2Collector(fakeVerifier{}).RecordShareSubmission(nil); err == nil { + t.Fatal("nil share must be rejected, not panic") + } + }) + t.Run("unknown attempt", func(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + if err := c.RecordShareSubmission(signedTestShareSubmission(t, 3, testSigningPackageHash())); !errors.Is(err, ErrRound2UnknownAttempt) { + t.Fatalf("want ErrRound2UnknownAttempt, got %v", err) + } + }) + t.Run("no signing package recorded yet", func(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + if err := c.BeginAttempt(pinnedContextHash[:], elected, testIncludedSet()); err != nil { + t.Fatalf("begin: %v", err) + } + if err := c.RecordShareSubmission(signedTestShareSubmission(t, 3, testSigningPackageHash())); !errors.Is(err, ErrRound2NoSigningPackage) { + t.Fatalf("want ErrRound2NoSigningPackage, got %v", err) + } + }) + t.Run("submitter not in included set", func(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + if err := c.BeginAttempt(pinnedContextHash[:], elected, []group.MemberIndex{5, 7}); err != nil { // excludes 3 + t.Fatalf("begin: %v", err) + } + pkg := signedTestSigningPackage(t, elected, nil) + if err := c.RecordSigningPackage(pkg); err != nil { + t.Fatalf("record package: %v", err) + } + pkgHash, _ := pkg.BodyHash() + if err := c.RecordShareSubmission(signedTestShareSubmission(t, 3, pkgHash[:])); !errors.Is(err, ErrRound2SubmitterNotIncluded) { + t.Fatalf("want ErrRound2SubmitterNotIncluded, got %v", err) + } + }) + t.Run("share with a bad submitter signature is rejected without retention", func(t *testing.T) { + c := NewRound2Collector(fakeVerifier{}) + pkgHash := recordTestPackage(t, c, elected) + sub := signedTestShareSubmission(t, 3, pkgHash) + sub.SubmitterSignature[0] ^= 0xff // tamper + if err := c.RecordShareSubmission(sub); !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("want ErrSignatureInvalid, got %v", err) + } + }) +} + +func TestRound2Collector_RecordShareSubmission_RetainsDivergentShare(t *testing.T) { + // A validly-signed, attempt-bound, included-member share that does NOT bind + // the authoritative package is RETAINED as divergent evidence (possible + // targeted coordinator equivocation), not dropped: it returns + // ErrShareRetainedNotAccepted and emits EquivocationKindDivergentShare. + captured := captureEquivocationEvidence(t) + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + _ = recordTestPackage(t, c, elected) + + wrong := bytes.Repeat([]byte{0x11}, SigningPackageHashLength) + if err := c.RecordShareSubmission(signedTestShareSubmission(t, 3, wrong)); !errors.Is(err, ErrShareRetainedNotAccepted) { + t.Fatalf("want ErrShareRetainedNotAccepted, got %v", err) + } + if len(*captured) != 1 || (*captured)[0].Kind != EquivocationKindDivergentShare { + t.Fatalf("expected 1 divergent_share event, got %d %+v", len(*captured), *captured) + } + if (*captured)[0].Sender != 3 || len((*captured)[0].ConflictingEnvelope) == 0 { + t.Fatal("divergent evidence must name the submitter and carry the share envelope") + } + // Re-recording the same divergent share is idempotent: still not accepted, no + // new evidence. + if err := c.RecordShareSubmission(signedTestShareSubmission(t, 3, wrong)); !errors.Is(err, ErrShareRetainedNotAccepted) { + t.Fatalf("re-record: want ErrShareRetainedNotAccepted, got %v", err) + } + if len(*captured) != 1 { + t.Fatalf("idempotent divergent re-record must not emit again, got %d", len(*captured)) + } +} + +func TestRound2Collector_RecordShareSubmission_DetectsMemberEquivocation(t *testing.T) { + captured := captureEquivocationEvidence(t) + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := recordTestPackage(t, c, elected) + + if err := c.RecordShareSubmission(signedTestShareSubmission(t, 3, pkgHash)); err != nil { + t.Fatalf("record first share: %v", err) + } + // Same submitter, DIFFERENT signed share body (different signature share). + conflicting := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: 3, + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("a-different-round2-share"), + } + if err := SignShareSubmission(&fakeSigner{id: 3}, conflicting); err != nil { + t.Fatalf("sign conflicting: %v", err) + } + if err := c.RecordShareSubmission(conflicting); !errors.Is(err, ErrShareConflict) { + t.Fatalf("want ErrShareConflict, got %v", err) + } + if len(*captured) != 1 { + t.Fatalf("expected 1 equivocation event, got %d", len(*captured)) + } + ev := (*captured)[0] + if ev.Kind != EquivocationKindShareConflict || ev.Sender != 3 { + t.Fatalf("want share_conflict from sender 3, got kind %q sender %d", ev.Kind, ev.Sender) + } + if len(ev.ExistingEnvelope) == 0 || len(ev.ConflictingEnvelope) == 0 || + bytes.Equal(ev.ExistingEnvelope, ev.ConflictingEnvelope) { + t.Fatal("both distinct share envelopes must be retained as evidence") + } +} + +func TestRound2Collector_RecordShareSubmission_EnvelopeReEncodingIsNotEquivocation(t *testing.T) { + captured := captureEquivocationEvidence(t) + c := NewRound2Collector(fakeVerifier{}) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := recordTestPackage(t, c, elected) + + share := signedTestShareSubmission(t, 3, pkgHash) + if err := c.RecordShareSubmission(share); err != nil { + t.Fatalf("record share: %v", err) + } + // Re-wrap the SAME (body, signature) in a reversed-field-order envelope. + body, _ := share.bodyBytes() + var reEncoded []byte + reEncoded = append(reEncoded, 0x12, byte(len(share.SubmitterSignature))) + reEncoded = append(reEncoded, share.SubmitterSignature...) + reEncoded = append(reEncoded, 0x0a, byte(len(body))) + reEncoded = append(reEncoded, body...) + var reDecoded ShareSubmission + if err := reDecoded.Unmarshal(reEncoded); err != nil { + t.Fatalf("unmarshal re-encoded: %v", err) + } + + if err := c.RecordShareSubmission(&reDecoded); err != nil { + t.Fatalf("a re-encoded same-body share must be idempotent, got %v", err) + } + if len(*captured) != 0 { + t.Fatalf("share envelope re-encoding must NOT emit equivocation, got %d", len(*captured)) + } +} diff --git a/pkg/frost/roast/seed_bridge.go b/pkg/frost/roast/seed_bridge.go new file mode 100644 index 0000000000..cfac471c59 --- /dev/null +++ b/pkg/frost/roast/seed_bridge.go @@ -0,0 +1,33 @@ +package roast + +import ( + "encoding/binary" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// foldAttemptSeed reduces an RFC-21 [32]byte AttemptSeed to the legacy +// int64 seed accepted by SelectCoordinator. The reduction takes the +// first 8 bytes of the seed as a big-endian uint64 and re-interprets +// the bits as int64. +// +// This is a sterile, named adapter, *not* a cryptographic reduction. +// Its only contract is determinism: byte-identical input must produce +// byte-identical int64 output on every honest signer, so the +// SelectCoordinator shuffle remains in agreement across the network. +// +// The remaining 24 bytes of the seed are deliberately ignored. They +// are still part of the seed binding (so any change to those bytes is +// detected at the AttemptContext.Hash() layer, which protocol +// messages already verify in Phase 1B), but they do not influence the +// shuffle. SelectCoordinator's math.Rand source is non-cryptographic +// and 64 bits of entropy are sufficient for its purpose. +// +// Callers must not compose foldAttemptSeed with additional hashing. +// If a future RFC requires a different reduction it must be a new +// named bridge with its own tests and migration story. +func foldAttemptSeed(seed [attempt.AttemptSeedLength]byte) int64 { + // #nosec G115 -- intentional uint64-to-int64 reinterpretation; the + // downstream rand.Source accepts any int64, including negative. + return int64(binary.BigEndian.Uint64(seed[:8])) +} diff --git a/pkg/frost/roast/seed_bridge_test.go b/pkg/frost/roast/seed_bridge_test.go new file mode 100644 index 0000000000..dcc68a6c6e --- /dev/null +++ b/pkg/frost/roast/seed_bridge_test.go @@ -0,0 +1,107 @@ +package roast + +import ( + "encoding/binary" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +func TestFoldAttemptSeed_IsDeterministic(t *testing.T) { + seed := [attempt.AttemptSeedLength]byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + } + a := foldAttemptSeed(seed) + b := foldAttemptSeed(seed) + if a != b { + t.Fatalf("foldAttemptSeed not deterministic: %d != %d", a, b) + } +} + +func TestFoldAttemptSeed_TakesFirst8BytesBigEndian(t *testing.T) { + seed := [attempt.AttemptSeedLength]byte{ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + } + got := foldAttemptSeed(seed) + if got != 1 { + t.Fatalf("first-8 BE decode wrong: got %d want 1", got) + } +} + +func TestFoldAttemptSeed_IgnoresBytesAfterIndex7(t *testing.T) { + // Document the contract: bytes 8..31 do not influence the output. + // Any change to those bytes is still caught at the + // AttemptContext.Hash() layer; the bridge merely surfaces the + // first 8. + base := [attempt.AttemptSeedLength]byte{ + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x11, 0x22, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + } + mutated := base + for i := 8; i < attempt.AttemptSeedLength; i++ { + mutated[i] ^= 0xff + } + if foldAttemptSeed(base) != foldAttemptSeed(mutated) { + t.Fatal( + "bridge must ignore bytes 8..31 by contract; honest signers " + + "will desynchronise if this assumption changes", + ) + } +} + +func TestFoldAttemptSeed_FirstByteSwept(t *testing.T) { + // Sweep the high byte of the leading uint64; every value must + // produce a distinct int64. + seen := map[int64]struct{}{} + for hi := 0; hi < 256; hi++ { + var seed [attempt.AttemptSeedLength]byte + seed[0] = byte(hi) + got := foldAttemptSeed(seed) + if _, dup := seen[got]; dup { + t.Fatalf("collision on high-byte sweep at %d", hi) + } + seen[got] = struct{}{} + } + if len(seen) != 256 { + t.Fatalf("expected 256 distinct outputs, got %d", len(seen)) + } +} + +func TestFoldAttemptSeed_GoldenFixture(t *testing.T) { + // Locks the wire-format reduction so any future change to the + // bridge implementation is caught at code review. Two coordinator + // instances that disagree on this constant will produce + // divergent SelectCoordinator outputs and fracture the network. + seed := [attempt.AttemptSeedLength]byte{ + 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, + } + want := int64(binary.BigEndian.Uint64(seed[:8])) + got := foldAttemptSeed(seed) + if got != want { + t.Fatalf( + "golden fixture drift: got %d want %d (seed=%x)", + got, want, seed[:8], + ) + } + // Also assert the literal integer so a typo in the reference + // computation above is caught: 0xdeadbeefcafebabe (16045690984503098046 + // as uint64) reinterpreted as int64. + const wantLiteral int64 = -2401053089206453570 + if got != wantLiteral { + t.Fatalf( + "golden fixture int64 drift: got %d want %d", + got, wantLiteral, + ) + } +} diff --git a/pkg/frost/roast/share_submission.go b/pkg/frost/roast/share_submission.go new file mode 100644 index 0000000000..4b2e5362af --- /dev/null +++ b/pkg/frost/roast/share_submission.go @@ -0,0 +1,328 @@ +package roast + +import ( + "crypto/sha256" + "errors" + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// SignedShareSubmissionType is the net.TaggedUnmarshaler Type() string for a +// member's signed Round2 share submission. +const SignedShareSubmissionType = roastMessageTypePrefix + "signed_share_submission" + +// shareSubmissionSignatureDomain is the fixed domain-separation tag prefixed to +// the bytes the submitting member signs (see SignableBytes). A member's operator +// key also signs evidence snapshots, and the elected coordinator's operator key +// signs transition messages and signing packages; each signed body must be +// non-confusable. Like the other ROAST signed bodies, the tag BEGINS with byte +// 0x00 - an illegal protobuf tag (field number 0) - so the signed payload is +// undecodable as any protobuf message: a share-submission signature cannot be +// accepted on another envelope (whose decoder rejects the 0x00-leading body), +// and another body's signature cannot verify against domain || body (a genuine +// protobuf body starts with a valid tag, >= 0x08). The tag is NOT carried on +// the wire - signer and verifier prepend the same constant. +var shareSubmissionSignatureDomain = []byte("\x00roast/signed-share-submission/v1\x00") + +// SigningPackageHashLength is the byte length of the signing_package_hash that +// binds a share submission to the package it answers - a SHA-256 of the +// authenticated SignedSigningPackage envelope. +const SigningPackageHashLength = 32 + +// MaxSignatureShareBytes caps the embedded FROST signature share. A round-2 +// share is a single scalar (~32 bytes); the cap leaves generous headroom for +// other schemes while rejecting pathological payloads at Unmarshal time. +const MaxSignatureShareBytes = 256 + +// MaxSignedShareSubmissionBytes bounds a whole SignedShareSubmission envelope so +// Unmarshal can reject a grossly oversized message before proto.Unmarshal +// materializes it. Sized as the share cap plus the operator-signature cap plus +// generous framing for the two 32-byte hashes and field overhead. +const MaxSignedShareSubmissionBytes = MaxSignatureShareBytes + MaxOperatorSignatureBytes + 512 + +// ShareSubmission is a member's Round2 FROST signature share bound to the +// attempt and to the exact SignedSigningPackage envelope the member +// authenticated, signed with the member's operator key. The binding is the +// hard prerequisite for blame adjudication (Phase 7.2b-4). +type ShareSubmission struct { + // AttemptContextHash binds the submission to one attempt. Exactly 32 bytes. + AttemptContextHash []byte + // SubmitterIDValue is the submitting member's index. A wire uint32; it must + // fit group.MemberIndex (uint8), enforced by Validate. + SubmitterIDValue uint32 + // CoordinatorIDValue is the elected coordinator this share is authorized + // for, as resolved when authenticating the signing package. A wire uint32 + // bounded to group.MemberIndex by Validate. + CoordinatorIDValue uint32 + // SigningPackageHash is the 32-byte SHA-256 of the signing-package BODY this + // share answers (SigningPackage.BodyHash) - the coordinator-signed content, + // stable across unsigned envelope re-encodings. + SigningPackageHash []byte + // SignatureShare is the serialized FROST round-2 signature share. + SignatureShare []byte + // SubmitterSignature is the submitting member's operator-key signature over + // SignableBytes(): the share-submission domain tag followed by the + // serialized ShareSubmissionBody. + SubmitterSignature []byte + + // bodyCache caches the exact serialized body bytes carried on the wire: + // marshaled once at signing time for a self-authored submission, or the + // received bytes verbatim for a parsed one. Fields must not be mutated once + // set. + bodyCache []byte + // signaturePayloadCache caches the domain-tagged bytes the + // SubmitterSignature covers (shareSubmissionSignatureDomain || bodyCache); + // primed at decode and never carried on the wire. + signaturePayloadCache []byte + // wireEnvelope caches the exact on-wire envelope (body + signature): the + // received bytes verbatim for parsed submissions, or built once after + // signing for self-authored ones. + wireEnvelope []byte +} + +func shareSubmissionBodyMessage(p *ShareSubmission) *pb.ShareSubmissionBody { + return &pb.ShareSubmissionBody{ + AttemptContextHash: p.AttemptContextHash, + SubmitterId: p.SubmitterIDValue, + CoordinatorId: p.CoordinatorIDValue, + SigningPackageHash: p.SigningPackageHash, + SignatureShare: p.SignatureShare, + } +} + +func shareSubmissionFieldsFromBody(p *ShareSubmission, body *pb.ShareSubmissionBody) { + p.AttemptContextHash = append([]byte(nil), body.AttemptContextHash...) + p.SubmitterIDValue = body.SubmitterId + p.CoordinatorIDValue = body.CoordinatorId + p.SigningPackageHash = append([]byte(nil), body.SigningPackageHash...) + p.SignatureShare = append([]byte(nil), body.SignatureShare...) +} + +// SubmitterID returns the submitting member index as a group.MemberIndex. +// Validate (or Unmarshal) must have confirmed it fits. +func (p *ShareSubmission) SubmitterID() group.MemberIndex { + return group.MemberIndex(p.SubmitterIDValue) +} + +// CoordinatorID returns the authorized coordinator index as a +// group.MemberIndex. Validate (or Unmarshal) must have confirmed it fits. +func (p *ShareSubmission) CoordinatorID() group.MemberIndex { + return group.MemberIndex(p.CoordinatorIDValue) +} + +// bodyBytes returns the exact serialized ShareSubmissionBody - the body carried +// verbatim in the SignedShareSubmission envelope. Marshaled once and cached for +// a self-authored submission; the received bytes verbatim for a parsed one. The +// returned slice is the internal cache - callers must not mutate it. +func (p *ShareSubmission) bodyBytes() ([]byte, error) { + if p == nil { + return nil, errors.New("roast: cannot encode a nil share submission") + } + if p.bodyCache != nil { + return p.bodyCache, nil + } + body, err := proto.Marshal(shareSubmissionBodyMessage(p)) + if err != nil { + return nil, fmt.Errorf("roast: marshal share submission body: %w", err) + } + p.bodyCache = body + return body, nil +} + +// SignableBytes returns the exact byte stream the SubmitterSignature covers: the +// share-submission domain tag (shareSubmissionSignatureDomain) followed by the +// serialized ShareSubmissionBody. The domain tag is a fixed constant prepended +// by both signer and verifier and is NOT carried on the wire - it +// domain-separates this signature from the node's other signed bodies. The body +// half is the bytes that travel: marshaled once for a self-authored submission, +// or the received body verbatim for a parsed one (verify exactly what was +// received). Fields must not be mutated afterwards, and the returned slice is +// the internal cache - callers must not mutate it. +func (p *ShareSubmission) SignableBytes() ([]byte, error) { + if p == nil { + return nil, errors.New("roast: cannot encode a nil share submission") + } + if p.signaturePayloadCache != nil { + return p.signaturePayloadCache, nil + } + body, err := p.bodyBytes() + if err != nil { + return nil, err + } + payload := make([]byte, 0, len(shareSubmissionSignatureDomain)+len(body)) + payload = append(payload, shareSubmissionSignatureDomain...) + payload = append(payload, body...) + p.signaturePayloadCache = payload + return payload, nil +} + +// BodyHash returns the SHA-256 of the submission's signed body bytes - the +// identity used to detect member equivocation (two different signed share bodies +// from one submitter for one attempt). It hashes the BODY (the signed content: +// attempt, coordinator, signing-package hash, and share), not the on-wire +// envelope, so it is stable across an unsigned envelope re-encoding. +func (p *ShareSubmission) BodyHash() ([sha256.Size]byte, error) { + body, err := p.bodyBytes() + if err != nil { + return [sha256.Size]byte{}, err + } + return sha256.Sum256(body), nil +} + +// Type implements net.TaggedUnmarshaler. +func (p *ShareSubmission) Type() string { + return SignedShareSubmissionType +} + +// Marshal serialises the submission as a SignedShareSubmission envelope: the +// serialized ShareSubmissionBody plus the submitter signature (which covers the +// domain-tagged body, see SignableBytes). For a submission parsed off the wire +// the received envelope is returned verbatim, so the bytes a verifier retains +// for the section-3 equivocation comparison are exactly the bytes it received. +// The submission must be signed first. The returned slice is the internal +// cache - callers must not mutate it. +func (p *ShareSubmission) Marshal() ([]byte, error) { + if p == nil { + return nil, errors.New("roast: cannot marshal a nil share submission") + } + if p.wireEnvelope != nil { + return p.wireEnvelope, nil + } + if len(p.SubmitterSignature) == 0 { + return nil, errors.New( + "roast: share submission must be signed before wire encoding", + ) + } + body, err := p.bodyBytes() + if err != nil { + return nil, err + } + envelope, err := proto.Marshal(&pb.SignedShareSubmission{ + Body: body, + SubmitterSignature: p.SubmitterSignature, + }) + if err != nil { + return nil, fmt.Errorf("roast: marshal share submission envelope: %w", err) + } + p.wireEnvelope = envelope + return envelope, nil +} + +// Unmarshal parses a SignedShareSubmission envelope, retains the received body +// and envelope bytes verbatim (the submitter signature is verified over exactly +// these bytes), populates the fields from the body, and validates the structure. +func (p *ShareSubmission) Unmarshal(data []byte) error { + if p == nil { + return errors.New("roast: cannot unmarshal into a nil share submission") + } + // Bound the input before allocating: reject a grossly oversized envelope + // before proto.Unmarshal materializes it (and before the copies below), so + // the caps protect memory rather than only rejecting after the fact. + if len(data) > MaxSignedShareSubmissionBytes { + return fmt.Errorf( + "signed share submission: envelope length [%d] exceeds cap [%d]", + len(data), + MaxSignedShareSubmissionBytes, + ) + } + var envelope pb.SignedShareSubmission + if err := proto.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("signed share submission: parse envelope: %w", err) + } + if len(envelope.Body) == 0 { + return errors.New("signed share submission: empty body") + } + var body pb.ShareSubmissionBody + if err := proto.Unmarshal(envelope.Body, &body); err != nil { + return fmt.Errorf("signed share submission: parse body: %w", err) + } + // Enforce the share cap on the parsed field before copying it into the + // struct (and before caching the body), so an over-cap field is rejected + // without the extra allocations. + if len(body.SignatureShare) > MaxSignatureShareBytes { + return fmt.Errorf( + "signed share submission: signatureShare length [%d] exceeds cap [%d]", + len(body.SignatureShare), + MaxSignatureShareBytes, + ) + } + shareSubmissionFieldsFromBody(p, &body) + p.SubmitterSignature = append([]byte(nil), envelope.SubmitterSignature...) + p.bodyCache = append([]byte(nil), envelope.Body...) + p.wireEnvelope = append([]byte(nil), data...) + // Prime the signable-bytes cache from the body just received, discarding any + // cache a prior SignableBytes call left on a reused value. Priming here - + // rather than lazily in SignableBytes - keeps concurrent signature + // verification of a parsed submission race-free. + p.signaturePayloadCache = nil + if _, err := p.SignableBytes(); err != nil { + return err + } + return p.Validate() +} + +// Validate runs the structural checks Unmarshal applies after a decode. Exposed +// so callers that construct submissions in memory can validate without a +// marshal/unmarshal round-trip. +func (p *ShareSubmission) Validate() error { + if p == nil { + return errors.New("share submission: nil") + } + if len(p.AttemptContextHash) != attempt.MessageDigestLength { + return fmt.Errorf( + "share submission: attemptContextHash length [%d], expected [%d]", + len(p.AttemptContextHash), + attempt.MessageDigestLength, + ) + } + if p.SubmitterIDValue == 0 { + return errors.New("share submission: submitterID is zero") + } + if p.SubmitterIDValue > group.MaxMemberIndex { + return fmt.Errorf( + "share submission: submitterID [%d] exceeds max member index [%d]", + p.SubmitterIDValue, + group.MaxMemberIndex, + ) + } + if p.CoordinatorIDValue == 0 { + return errors.New("share submission: coordinatorID is zero") + } + if p.CoordinatorIDValue > group.MaxMemberIndex { + return fmt.Errorf( + "share submission: coordinatorID [%d] exceeds max member index [%d]", + p.CoordinatorIDValue, + group.MaxMemberIndex, + ) + } + if len(p.SigningPackageHash) != SigningPackageHashLength { + return fmt.Errorf( + "share submission: signingPackageHash length [%d], expected [%d]", + len(p.SigningPackageHash), + SigningPackageHashLength, + ) + } + if len(p.SignatureShare) == 0 { + return errors.New("share submission: signatureShare is empty") + } + if len(p.SignatureShare) > MaxSignatureShareBytes { + return fmt.Errorf( + "share submission: signatureShare length [%d] exceeds cap [%d]", + len(p.SignatureShare), + MaxSignatureShareBytes, + ) + } + if len(p.SubmitterSignature) > MaxOperatorSignatureBytes { + return fmt.Errorf( + "share submission: submitterSignature length [%d] exceeds cap [%d]", + len(p.SubmitterSignature), + MaxOperatorSignatureBytes, + ) + } + return nil +} diff --git a/pkg/frost/roast/share_submission_auth.go b/pkg/frost/roast/share_submission_auth.go new file mode 100644 index 0000000000..955762db72 --- /dev/null +++ b/pkg/frost/roast/share_submission_auth.go @@ -0,0 +1,167 @@ +package roast + +import ( + "bytes" + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ErrShareSubmissionWrongCoordinator is returned by AuthenticateShareSubmission +// when a share names a coordinator other than the attempt's elected coordinator +// (RFC-21 Annex A). A member that resolved a different coordinator (e.g. under a +// partition) must not have its share accepted into this attempt. +var ErrShareSubmissionWrongCoordinator = errors.New( + "roast: share submission coordinator is not the attempt's elected coordinator", +) + +// ErrShareSubmissionWrongAttempt is returned when a share's attempt_context_hash +// does not match the live attempt. +var ErrShareSubmissionWrongAttempt = errors.New( + "roast: share submission attempt context hash does not match the live attempt", +) + +// ErrShareSubmissionWrongPackage is returned when a share's signing_package_hash +// does not match the signing package the coordinator distributed for the attempt +// - the share answers a different or stale package. +var ErrShareSubmissionWrongPackage = errors.New( + "roast: share submission signing package hash does not match the live package", +) + +// SignShareSubmission signs sub with the submitting member's operator key, +// setting sub.SubmitterSignature over sub.SignableBytes() (the domain-tagged +// body). A member calls this after authenticating the signing package and +// accepting its taproot root, to return its round-2 share. sub must be +// structurally valid (call Validate first). +func SignShareSubmission(signer Signer, sub *ShareSubmission) error { + payload, err := sub.SignableBytes() + if err != nil { + return err + } + signature, err := signer.Sign(payload) + if err != nil { + return fmt.Errorf("roast: sign share submission: %w", err) + } + sub.SubmitterSignature = signature + return nil +} + +// AuthenticateShareSubmission verifies that sub is a genuine round-2 share from +// its declared submitter, for this exact attempt and package: it names +// electedCoordinator, its attempt_context_hash matches the live attempt, its +// signing_package_hash matches the package the coordinator distributed +// (liveSigningPackageHash), and its signature verifies under the submitter's +// operator key over the domain-tagged body. (electedCoordinator and +// liveSigningPackageHash are resolved by the caller from the attempt and the +// distributed SignedSigningPackage - see SigningPackage.BodyHash.) +// +// The signature check is over sub.SubmitterID(), so a forged submitter_id does +// not verify: the signature binds the declared submitter to the actual signer. +// +// A submission that passes is attributable to its submitter and bound to the +// package, so the caller MUST retain its exact received bytes for the +// cross-member equivocation comparison (Phase 7.2b-4). A submission that fails +// any check is forgeable or misdirected noise: the caller rejects it WITHOUT +// retaining it. Membership of the submitter in the included set and de-dup of +// repeated shares are the caller's responsibility, not this function's. +func AuthenticateShareSubmission( + verifier SignatureVerifier, + sub *ShareSubmission, + electedCoordinator group.MemberIndex, + liveAttemptContextHash []byte, + liveSigningPackageHash []byte, +) error { + // Structurally validate first: this is an authentication boundary for + // untrusted input, and the checks below use the truncating ID accessor and + // bytes.Equal. A manually-assembled (un-Unmarshaled) submission must be + // rejected before any field is trusted - e.g. a submitter_id that truncates + // to another member (uint32 -> uint8), or empty hashes that would make + // bytes.Equal(nil, nil) pass. + if err := sub.Validate(); err != nil { + return fmt.Errorf("share submission failed structural validation: %w", err) + } + if len(sub.SubmitterSignature) == 0 { + return fmt.Errorf( + "%w: share submission has no submitter signature", + ErrSignatureMissing, + ) + } + if sub.CoordinatorID() != electedCoordinator { + return fmt.Errorf( + "%w: share coordinator %d, elected %d", + ErrShareSubmissionWrongCoordinator, + sub.CoordinatorID(), + electedCoordinator, + ) + } + if !bytes.Equal(sub.AttemptContextHash, liveAttemptContextHash) { + return ErrShareSubmissionWrongAttempt + } + if !bytes.Equal(sub.SigningPackageHash, liveSigningPackageHash) { + return ErrShareSubmissionWrongPackage + } + payload, err := sub.SignableBytes() + if err != nil { + return fmt.Errorf("share submission signable bytes: %w", err) + } + if err := verifier.Verify( + payload, + sub.SubmitterSignature, + sub.SubmitterID(), + ); err != nil { + return fmt.Errorf( + "%w: submitter %d: %s", + ErrSignatureInvalid, + sub.SubmitterID(), + err.Error(), + ) + } + return nil +} + +// verifyShareSubmissionForAttempt verifies the submitter signature on an +// already-validated share for the live attempt WITHOUT the strict package / +// coordinator binding that AuthenticateShareSubmission requires. It confirms only +// that sub is a genuine, attempt-bound share from its declared submitter. +// +// The Round2Collector uses this to RETAIN a submitter-signed share that diverges +// from the attempt's authoritative package (a different signing-package hash or +// coordinator) as blame evidence, rather than dropping it: the collector's +// contract is to preserve the exact bytes adjudication needs, and a dropped +// divergent share is targeted coordinator equivocation that 7.2b-4 could never +// recover. Classification (coordinator vs member fault) is deferred to the f+1 +// quorum compare. sub.Validate() must have already passed (so SubmitterID() is +// bounded). +func verifyShareSubmissionForAttempt( + verifier SignatureVerifier, + sub *ShareSubmission, + liveAttemptContextHash []byte, +) error { + if len(sub.SubmitterSignature) == 0 { + return fmt.Errorf( + "%w: share submission has no submitter signature", + ErrSignatureMissing, + ) + } + if !bytes.Equal(sub.AttemptContextHash, liveAttemptContextHash) { + return ErrShareSubmissionWrongAttempt + } + payload, err := sub.SignableBytes() + if err != nil { + return fmt.Errorf("share submission signable bytes: %w", err) + } + if err := verifier.Verify( + payload, + sub.SubmitterSignature, + sub.SubmitterID(), + ); err != nil { + return fmt.Errorf( + "%w: submitter %d: %s", + ErrSignatureInvalid, + sub.SubmitterID(), + err.Error(), + ) + } + return nil +} diff --git a/pkg/frost/roast/share_submission_auth_test.go b/pkg/frost/roast/share_submission_auth_test.go new file mode 100644 index 0000000000..009c957fac --- /dev/null +++ b/pkg/frost/roast/share_submission_auth_test.go @@ -0,0 +1,241 @@ +package roast + +import ( + "bytes" + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestSignShareSubmission_RoundTripAuthenticates(t *testing.T) { + const submitter = group.MemberIndex(3) + pkgHash := testSigningPackageHash() + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: uint32(submitter), + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("frost-round2-share"), + } + if err := SignShareSubmission(&fakeSigner{id: submitter}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + if len(sub.SubmitterSignature) == 0 { + t.Fatal("SignShareSubmission must set a submitter signature") + } + + // The coordinator receives the submission off the wire and authenticates it. + wire, err := sub.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + var received ShareSubmission + if err := received.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := AuthenticateShareSubmission( + fakeVerifier{}, + &received, + group.MemberIndex(testShareCoordinatorID), + pinnedContextHash[:], + pkgHash, + ); err != nil { + t.Fatalf("authenticate a genuine submission: %v", err) + } +} + +func TestAuthenticateShareSubmission_Rejections(t *testing.T) { + const submitter = group.MemberIndex(3) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := testSigningPackageHash() + signed := func() *ShareSubmission { + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: uint32(submitter), + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("share"), + } + if err := SignShareSubmission(&fakeSigner{id: submitter}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + return sub + } + otherAttempt := bytes.Repeat([]byte{0x09}, attempt.MessageDigestLength) + + t.Run("missing signature is rejected", func(t *testing.T) { + sub := signed() + sub.SubmitterSignature = nil + err := AuthenticateShareSubmission(fakeVerifier{}, sub, elected, pinnedContextHash[:], pkgHash) + if !errors.Is(err, ErrSignatureMissing) { + t.Fatalf("want ErrSignatureMissing, got %v", err) + } + }) + + t.Run("non-elected coordinator is rejected", func(t *testing.T) { + err := AuthenticateShareSubmission(fakeVerifier{}, signed(), elected+1, pinnedContextHash[:], pkgHash) + if !errors.Is(err, ErrShareSubmissionWrongCoordinator) { + t.Fatalf("want ErrShareSubmissionWrongCoordinator, got %v", err) + } + }) + + t.Run("wrong attempt is rejected", func(t *testing.T) { + err := AuthenticateShareSubmission(fakeVerifier{}, signed(), elected, otherAttempt, pkgHash) + if !errors.Is(err, ErrShareSubmissionWrongAttempt) { + t.Fatalf("want ErrShareSubmissionWrongAttempt, got %v", err) + } + }) + + t.Run("wrong package is rejected", func(t *testing.T) { + otherPkg := bytes.Repeat([]byte{0x11}, SigningPackageHashLength) + err := AuthenticateShareSubmission(fakeVerifier{}, signed(), elected, pinnedContextHash[:], otherPkg) + if !errors.Is(err, ErrShareSubmissionWrongPackage) { + t.Fatalf("want ErrShareSubmissionWrongPackage, got %v", err) + } + }) + + t.Run("tampered signature fails verification", func(t *testing.T) { + sub := signed() + sub.SubmitterSignature[0] ^= 0xff + err := AuthenticateShareSubmission(fakeVerifier{}, sub, elected, pinnedContextHash[:], pkgHash) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("want ErrSignatureInvalid, got %v", err) + } + }) + + t.Run("submission signed by a non-submitter is rejected", func(t *testing.T) { + // A different operator signs a body carrying submitter_id=3; the + // signature does not verify under member 3's key. + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: uint32(submitter), + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("share"), + } + if err := SignShareSubmission(&fakeSigner{id: submitter + 7}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + err := AuthenticateShareSubmission(fakeVerifier{}, sub, elected, pinnedContextHash[:], pkgHash) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("want ErrSignatureInvalid, got %v", err) + } + }) + + t.Run("structurally invalid submission is rejected before verification", func(t *testing.T) { + // submitter_id 259 truncates to member 3 (uint32 -> uint8); signed by + // member 3 it would otherwise verify AS member 3 despite the wire id. The + // structural pre-check (submitter_id > MaxMemberIndex) rejects it before + // any signature is trusted. + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: 259, + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("share"), + } + if err := SignShareSubmission(&fakeSigner{id: 3}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + if err := AuthenticateShareSubmission(fakeVerifier{}, sub, elected, pinnedContextHash[:], pkgHash); err == nil { + t.Fatal("an out-of-range submitter_id must be rejected before verification") + } + }) +} + +func TestShareSubmissionBindsToSigningPackageBody(t *testing.T) { + // End-to-end: a share bound to a real signing package's BodyHash + // authenticates against that hash, and a share checked against a different + // package's hash is rejected. + const ( + submitter = group.MemberIndex(3) + coordinator = group.MemberIndex(7) + ) + pkg := signedTestSigningPackage(t, coordinator, nil) + pkgHash, err := pkg.BodyHash() + if err != nil { + t.Fatalf("body hash: %v", err) + } + + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: uint32(submitter), + CoordinatorIDValue: uint32(coordinator), + SigningPackageHash: pkgHash[:], + SignatureShare: []byte("share"), + } + if err := SignShareSubmission(&fakeSigner{id: submitter}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + if err := AuthenticateShareSubmission( + fakeVerifier{}, sub, coordinator, pinnedContextHash[:], pkgHash[:], + ); err != nil { + t.Fatalf("authenticate against the bound package: %v", err) + } + + // A different package -> different envelope hash -> rejected. + otherPkg := signedTestSigningPackage(t, coordinator, bytes.Repeat([]byte{0xcd}, TaprootMerkleRootLength)) + otherHash, err := otherPkg.BodyHash() + if err != nil { + t.Fatalf("other body hash: %v", err) + } + if bytes.Equal(pkgHash[:], otherHash[:]) { + t.Fatal("sanity: distinct packages must have distinct envelope hashes") + } + if err := AuthenticateShareSubmission( + fakeVerifier{}, sub, coordinator, pinnedContextHash[:], otherHash[:], + ); !errors.Is(err, ErrShareSubmissionWrongPackage) { + t.Fatalf("want ErrShareSubmissionWrongPackage, got %v", err) + } +} + +func TestSigningPackageBodyHash_StableAcrossWireAndReEncoding(t *testing.T) { + pkg := signedTestSigningPackage(t, 3, nil) + wire, err := pkg.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + producerHash, err := pkg.BodyHash() + if err != nil { + t.Fatalf("producer hash: %v", err) + } + + var received SigningPackage + if err := received.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + receivedHash, err := received.BodyHash() + if err != nil { + t.Fatalf("received hash: %v", err) + } + if producerHash != receivedHash { + t.Fatal("body hash must match for producer and receiver over the same bytes") + } + + // It must also be stable across an unsigned ENVELOPE re-encoding (reversed + // field order) of the same (body, signature) - the property that stops a MitM + // re-wrap from looking like a different package or fragmenting share bindings. + body, _ := pkg.bodyBytes() + var reEncoded []byte + reEncoded = append(reEncoded, 0x12, byte(len(pkg.CoordinatorSignature))) + reEncoded = append(reEncoded, pkg.CoordinatorSignature...) + reEncoded = append(reEncoded, 0x0a, byte(len(body))) + reEncoded = append(reEncoded, body...) + + var reDecoded SigningPackage + if err := reDecoded.Unmarshal(reEncoded); err != nil { + t.Fatalf("unmarshal re-encoded: %v", err) + } + reHash, err := reDecoded.BodyHash() + if err != nil { + t.Fatalf("re-encoded hash: %v", err) + } + if reHash != producerHash { + t.Fatal("body hash must be stable across an unsigned envelope re-encoding") + } + if reWire, _ := reDecoded.Marshal(); bytes.Equal(reWire, wire) { + t.Fatal("sanity: the re-encoded envelope must differ from the canonical envelope") + } +} diff --git a/pkg/frost/roast/share_submission_test.go b/pkg/frost/roast/share_submission_test.go new file mode 100644 index 0000000000..a62f65f9af --- /dev/null +++ b/pkg/frost/roast/share_submission_test.go @@ -0,0 +1,293 @@ +package roast + +import ( + "bytes" + "sync" + "testing" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// These pin the signed-body envelope contract for a member's Round2 share +// submission: the member signs exactly the bytes that travel, those bytes +// survive re-broadcast verbatim, the signed preimage is domain-separated from +// every other ROAST signed body, and parsing never depends on a serializer's +// canonical form. Member-side authentication arrives with a later increment. + +func testSigningPackageHash() []byte { + return bytes.Repeat([]byte{0xab}, SigningPackageHashLength) +} + +// testShareCoordinatorID is a fixed elected-coordinator index used by the +// share-submission fixtures (distinct from the submitter ids under test). +const testShareCoordinatorID = uint32(5) + +func signedTestShareSubmission( + t *testing.T, + submitter group.MemberIndex, + pkgHash []byte, +) *ShareSubmission { + t.Helper() + p := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: uint32(submitter), + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("frost-round2-signature-share"), + } + payload, err := p.SignableBytes() + if err != nil { + t.Fatalf("signable bytes: %v", err) + } + sig, err := (&fakeSigner{id: submitter}).Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + p.SubmitterSignature = sig + return p +} + +func TestShareSubmissionWire_ReceivedBytesPreservedVerbatim(t *testing.T) { + original := signedTestShareSubmission(t, 3, testSigningPackageHash()) + wire, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + decoded := &ShareSubmission{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + rebroadcast, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(rebroadcast, wire) { + t.Fatal("re-marshal of a received share submission must return the received bytes verbatim") + } + + producerBody, _ := original.SignableBytes() + receiverBody, _ := decoded.SignableBytes() + if !bytes.Equal(producerBody, receiverBody) { + t.Fatal("receiver must verify over exactly the bytes the submitter signed") + } + if decoded.SubmitterIDValue != original.SubmitterIDValue || + decoded.CoordinatorIDValue != original.CoordinatorIDValue || + !bytes.Equal(decoded.AttemptContextHash, original.AttemptContextHash) || + !bytes.Equal(decoded.SigningPackageHash, original.SigningPackageHash) || + !bytes.Equal(decoded.SignatureShare, original.SignatureShare) || + !bytes.Equal(decoded.SubmitterSignature, original.SubmitterSignature) { + t.Fatal("decoded fields must match the original") + } +} + +func TestShareSubmissionWire_NonCanonicalEnvelopeEncodingSurvives(t *testing.T) { + original := signedTestShareSubmission(t, 3, testSigningPackageHash()) + body, err := original.bodyBytes() + if err != nil { + t.Fatalf("body bytes: %v", err) + } + + // Handcraft an envelope with fields in REVERSE tag order + // (submitter_signature before body) - wire-legal but non-canonical. Field 1 + // (body) tag 0x0a, field 2 (submitter_signature) tag 0x12; both + // length-delimited. + var crafted []byte + crafted = append(crafted, 0x12, byte(len(original.SubmitterSignature))) + crafted = append(crafted, original.SubmitterSignature...) + crafted = append(crafted, 0x0a, byte(len(body))) + crafted = append(crafted, body...) + + var check pb.SignedShareSubmission + if err := proto.Unmarshal(crafted, &check); err != nil { + t.Fatalf("crafted envelope must be wire-legal: %v", err) + } + + decoded := &ShareSubmission{} + if err := decoded.Unmarshal(crafted); err != nil { + t.Fatalf("unmarshal crafted: %v", err) + } + if gotBody, _ := decoded.bodyBytes(); !bytes.Equal(gotBody, body) { + t.Fatal("the received body must be preserved verbatim") + } + remarshaled, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(remarshaled, crafted) { + t.Fatal("re-marshal must preserve even a non-canonical received encoding verbatim") + } +} + +func TestShareSubmissionWire_DomainSeparatedAndUndecodable(t *testing.T) { + p := signedTestShareSubmission(t, 3, testSigningPackageHash()) + signable, err := p.SignableBytes() + if err != nil { + t.Fatalf("signable: %v", err) + } + body, _ := p.bodyBytes() + + // SignableBytes = share-submission domain tag || bare body. + if !bytes.HasPrefix(signable, shareSubmissionSignatureDomain) { + t.Fatal("signed bytes must carry the share-submission domain tag") + } + if !bytes.Equal(signable[len(shareSubmissionSignatureDomain):], body) { + t.Fatal("signed bytes must be the domain tag followed by the bare body") + } + // The signed bytes begin with an illegal protobuf tag and are undecodable. + if signable[0] != 0x00 { + t.Fatal("signed bytes must begin with an illegal protobuf tag (0x00)") + } + if err := proto.Unmarshal(signable, &pb.ShareSubmissionBody{}); err == nil { + t.Fatal("domain-tagged signed bytes must not decode as a protobuf message") + } + // The bare wire body carries no tag and is a valid protobuf body. + if bytes.HasPrefix(body, shareSubmissionSignatureDomain) { + t.Fatal("the wire body must not carry the domain tag") + } + if err := proto.Unmarshal(body, &pb.ShareSubmissionBody{}); err != nil { + t.Fatalf("the bare wire body must be a valid protobuf body: %v", err) + } +} + +func TestShareSubmissionDomain_DistinctFromOtherSignedBodies(t *testing.T) { + // The share-submission tag must be distinct and prefix-free from every other + // signed-body domain in the package, so a share signature can never be + // confused with a signing-package, snapshot, or transition signature. + others := map[string][]byte{ + "signing-package": signingPackageSignatureDomain, + "evidence-snapshot": localEvidenceSnapshotSignatureDomain, + "transition": transitionMessageSignatureDomain, + } + share := shareSubmissionSignatureDomain + if share[0] != 0x00 { + t.Fatal("share-submission domain must begin with an illegal protobuf tag (0x00)") + } + for name, other := range others { + if bytes.Equal(share, other) { + t.Fatalf("share-submission domain must differ from the %s domain", name) + } + if bytes.HasPrefix(share, other) || bytes.HasPrefix(other, share) { + t.Fatalf("share-submission domain must be prefix-free vs the %s domain", name) + } + } +} + +func TestShareSubmission_ValidateRejectsMalformed(t *testing.T) { + valid := func() *ShareSubmission { + return &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: 3, + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: testSigningPackageHash(), + SignatureShare: []byte("share"), + } + } + if err := valid().Validate(); err != nil { + t.Fatalf("a well-formed submission must validate: %v", err) + } + for _, tc := range []struct { + name string + mutate func(*ShareSubmission) + }{ + {"short attempt hash", func(p *ShareSubmission) { p.AttemptContextHash = []byte{1, 2, 3} }}, + {"zero submitter", func(p *ShareSubmission) { p.SubmitterIDValue = 0 }}, + {"submitter out of member-index range", func(p *ShareSubmission) { + p.SubmitterIDValue = group.MaxMemberIndex + 1 + }}, + {"zero coordinator", func(p *ShareSubmission) { p.CoordinatorIDValue = 0 }}, + {"coordinator out of member-index range", func(p *ShareSubmission) { + p.CoordinatorIDValue = group.MaxMemberIndex + 1 + }}, + {"short signing package hash", func(p *ShareSubmission) { p.SigningPackageHash = []byte{0x01} }}, + {"empty signature share", func(p *ShareSubmission) { p.SignatureShare = nil }}, + {"oversize signature share", func(p *ShareSubmission) { + p.SignatureShare = make([]byte, MaxSignatureShareBytes+1) + }}, + {"oversize submitter signature", func(p *ShareSubmission) { + p.SubmitterSignature = make([]byte, MaxOperatorSignatureBytes+1) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + p := valid() + tc.mutate(p) + if err := p.Validate(); err == nil { + t.Fatal("expected Validate to reject the malformed submission") + } + }) + } +} + +func TestShareSubmission_MarshalRequiresSignature(t *testing.T) { + p := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: 3, + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: testSigningPackageHash(), + SignatureShare: []byte("share"), + } + if _, err := p.Marshal(); err == nil { + t.Fatal("Marshal must refuse an unsigned share submission") + } +} + +func TestShareSubmissionWire_UnmarshalRejectsOversizeBeforeCopy(t *testing.T) { + // A peer-supplied envelope whose signature_share exceeds the cap is rejected + // on receive, so the cap protects memory rather than only failing after the + // field is materialized and copied. + oversized := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: 3, + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: testSigningPackageHash(), + SignatureShare: make([]byte, MaxSignatureShareBytes+1), + } + payload, err := oversized.SignableBytes() + if err != nil { + t.Fatalf("signable: %v", err) + } + sig, err := (&fakeSigner{id: 3}).Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + oversized.SubmitterSignature = sig + wire, err := oversized.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var decoded ShareSubmission + if err := decoded.Unmarshal(wire); err == nil { + t.Fatal("Unmarshal must reject an over-cap signature share") + } +} + +func TestShareSubmission_ConcurrentSignableBytesAfterUnmarshalIsRaceFree(t *testing.T) { + // Regression guard (run under -race): a parsed submission must carry a primed + // signable-bytes cache so concurrent signature verification reads a ready + // cache instead of racing on lazy initialization. + wire, err := signedTestShareSubmission(t, 3, testSigningPackageHash()).Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ShareSubmission + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := decoded.SignableBytes(); err != nil { + t.Errorf("signable: %v", err) + } + }() + } + wg.Wait() +} diff --git a/pkg/frost/roast/signature.go b/pkg/frost/roast/signature.go new file mode 100644 index 0000000000..fe17bdc664 --- /dev/null +++ b/pkg/frost/roast/signature.go @@ -0,0 +1,225 @@ +package roast + +import ( + "bytes" + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// Signer produces operator-key signatures over canonical-encoded +// payloads. The ROAST coordinator state machine uses one Signer per +// node to sign its own LocalEvidenceSnapshot before broadcast, and +// the elected coordinator uses the same Signer to sign the assembled +// TransitionMessage bundle. +// +// Phase 3.3 (this file) defines the interface. Phase 4 wires it to +// pkg/net's operator-key signing surface so signatures are +// automatically attributable to the node's libp2p identity. +// +// Implementations must be safe for concurrent calls from multiple +// goroutines. +type Signer interface { + // Sign returns a signature over the canonical payload produced + // by LocalEvidenceSnapshot.SignableBytes or + // TransitionMessage.SignableBytes. The + // returned signature is treated as opaque bytes by the + // coordinator state machine; the SignatureVerifier is the only + // component that interprets the byte sequence. + Sign(payload []byte) ([]byte, error) +} + +// SignatureVerifier verifies a signature attributed to a specific +// member. The verifier owns the member-to-public-key mapping; the +// coordinator state machine does not see public keys directly. +// +// Phase 3.3 (this file) defines the interface. Phase 4 wires it to +// pkg/net's member-keys table. +// +// Implementations must be safe for concurrent calls from multiple +// goroutines. +type SignatureVerifier interface { + // Verify returns nil if signature is a valid signature over + // payload produced by the operator key of signer. Returns a + // descriptive error otherwise. + Verify(payload []byte, signature []byte, signer group.MemberIndex) error +} + +// ErrSignatureInvalid is the canonical sentinel a SignatureVerifier +// returns when a signature does not validate against the supplied +// payload and signer. Callers that want to distinguish +// signature-verification failure from other errors should use +// errors.Is(err, ErrSignatureInvalid). +var ErrSignatureInvalid = errors.New("roast: signature is invalid") + +// ErrSignatureMissing is returned by VerifyBundle when a snapshot +// or bundle lacks the signature the protocol requires. +var ErrSignatureMissing = errors.New("roast: signature missing") + +// ErrCensorshipDetected is returned by VerifyBundle when a receiver +// finds its own LocalEvidenceSnapshot absent from a bundle the +// receiver expected to be present in. The receiver's snapshot is +// missing either because the elected coordinator dropped it +// (malicious or otherwise) or because the bundle was constructed +// before the receiver's submission arrived. In either case, the +// receiver must not feed the bundle into NextAttempt. +var ErrCensorshipDetected = errors.New( + "roast: own evidence snapshot missing from transition bundle (censorship or race)", +) + +// NoOpSigner returns a Signer whose Sign returns an empty signature. +// Suitable as a default in tests that do not exercise the signature +// pipeline, and as the implicit default of NewInMemoryCoordinator +// (which is preserved for backward compatibility with Phase 3.1 +// callers). +// +// A NoOpSigner-produced bundle is rejected by any non-NoOp verifier: +// the verifier sees a missing signature and fails closed. So the +// pair {NoOpSigner, NoOpSignatureVerifier} is only suitable when the +// caller wants to test the structural-aggregation pipeline in +// isolation from the crypto pipeline. +func NoOpSigner() Signer { return noOpSigner{} } + +// NoOpSignatureVerifier returns a SignatureVerifier that accepts +// every signature, including empty ones. Use ONLY in tests that do +// not exercise the signature pipeline. +func NoOpSignatureVerifier() SignatureVerifier { return noOpSignatureVerifier{} } + +type noOpSigner struct{} + +func (noOpSigner) Sign(_ []byte) ([]byte, error) { return nil, nil } + +type noOpSignatureVerifier struct{} + +func (noOpSignatureVerifier) Verify(_, _ []byte, _ group.MemberIndex) error { + return nil +} + +// verifySnapshotSignature checks the OperatorSignature on a single +// LocalEvidenceSnapshot against the verifier's record of the +// snapshot's sender's operator key. +func verifySnapshotSignature( + verifier SignatureVerifier, + snapshot *LocalEvidenceSnapshot, +) error { + if len(snapshot.OperatorSignature) == 0 { + return fmt.Errorf( + "%w: snapshot from sender %d has no operator signature", + ErrSignatureMissing, + snapshot.SenderID(), + ) + } + payload, err := snapshot.SignableBytes() + if err != nil { + return fmt.Errorf("snapshot signable bytes: %w", err) + } + if err := verifier.Verify( + payload, + snapshot.OperatorSignature, + snapshot.SenderID(), + ); err != nil { + return fmt.Errorf( + "%w: sender %d: %s", + ErrSignatureInvalid, + snapshot.SenderID(), + err.Error(), + ) + } + return nil +} + +// verifyBundleSignature checks the CoordinatorSignature on a +// TransitionMessage against the verifier's record of the bundle's +// declared coordinator's operator key. The coordinator member index +// passed in must match the elected coordinator for the attempt; the +// caller (Coordinator.VerifyBundle) resolves this from the +// AttemptHandle. +func verifyBundleSignature( + verifier SignatureVerifier, + msg *TransitionMessage, + expectedCoordinator group.MemberIndex, +) error { + if len(msg.CoordinatorSignature) == 0 { + return fmt.Errorf( + "%w: transition message has no coordinator signature", + ErrSignatureMissing, + ) + } + if msg.CoordinatorID() != expectedCoordinator { + return fmt.Errorf( + "transition message coordinator id %d does not match expected %d for the attempt", + msg.CoordinatorID(), + expectedCoordinator, + ) + } + payload, err := msg.SignableBytes() + if err != nil { + return fmt.Errorf("bundle signable bytes: %w", err) + } + if err := verifier.Verify( + payload, + msg.CoordinatorSignature, + msg.CoordinatorID(), + ); err != nil { + return fmt.Errorf( + "%w: coordinator %d: %s", + ErrSignatureInvalid, + msg.CoordinatorID(), + err.Error(), + ) + } + return nil +} + +// verifyOwnObservationsPresent is the receiver-side censorship- +// detection check: every receiver that has already submitted its +// own LocalEvidenceSnapshot to the elected coordinator must find +// that snapshot in the resulting bundle. A coordinator that drops a +// receiver's snapshot is detected here. +// +// When selfMember is zero, the check is skipped: that signals a +// caller that has not (yet) submitted its own snapshot and therefore +// has no censorship claim to verify. +func verifyOwnObservationsPresent( + msg *TransitionMessage, + selfMember group.MemberIndex, + selfSubmission *LocalEvidenceSnapshot, +) error { + if selfMember == 0 || selfSubmission == nil { + return nil + } + for i := range msg.Bundle { + if msg.Bundle[i].SenderID() != selfMember { + continue + } + // Found the receiver's snapshot. The submitted-vs-bundled + // signature must be byte-identical -- a coordinator that + // re-signed or mutated the submission has tampered with + // observed evidence. + if !bytes.Equal( + msg.Bundle[i].OperatorSignature, + selfSubmission.OperatorSignature, + ) { + emitEquivocationEvidence(EquivocationEvidence{ + Kind: EquivocationKindOwnSnapshotMutatedInBundle, + AttemptContextHash: append([]byte(nil), msg.AttemptContextHash...), + Sender: selfMember, + ExistingEnvelope: snapshotEnvelopeForEvidence(selfSubmission), + ConflictingEnvelope: snapshotEnvelopeForEvidence(&msg.Bundle[i]), + }) + return fmt.Errorf( + "%w: own evidence snapshot signature mutated in bundle", + ErrCensorshipDetected, + ) + } + return nil + } + emitEquivocationEvidence(EquivocationEvidence{ + Kind: EquivocationKindOwnSnapshotMissingFromBundle, + AttemptContextHash: append([]byte(nil), msg.AttemptContextHash...), + Sender: selfMember, + ExistingEnvelope: snapshotEnvelopeForEvidence(selfSubmission), + }) + return ErrCensorshipDetected +} diff --git a/pkg/frost/roast/signature_test.go b/pkg/frost/roast/signature_test.go new file mode 100644 index 0000000000..f7f3cc150a --- /dev/null +++ b/pkg/frost/roast/signature_test.go @@ -0,0 +1,262 @@ +package roast + +import ( + "bytes" + "crypto/sha256" + "errors" + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// fakeSigner produces deterministic signatures of the form +// SHA256(memberID || payload) so tests can exercise the sign / verify +// pipeline without real crypto. Two fakeSigners with the same member +// id produce identical signatures. +type fakeSigner struct { + id group.MemberIndex +} + +func (f *fakeSigner) Sign(payload []byte) ([]byte, error) { + h := sha256.New() + h.Write([]byte{byte(f.id)}) + h.Write(payload) + return h.Sum(nil), nil +} + +// fakeVerifier mirrors fakeSigner's deterministic signature scheme so +// every member's signatures verify against the same recomputation. +// A signature attributed to memberID is valid iff it equals +// SHA256(memberID || payload). +type fakeVerifier struct{} + +func (fakeVerifier) Verify(payload, signature []byte, signer group.MemberIndex) error { + h := sha256.New() + h.Write([]byte{byte(signer)}) + h.Write(payload) + expected := h.Sum(nil) + if !bytes.Equal(expected, signature) { + return errors.New("fakeVerifier: signature does not match recomputed value") + } + return nil +} + +func TestNoOpSigner_ReturnsEmptySignature(t *testing.T) { + sig, err := NoOpSigner().Sign([]byte("payload")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sig) != 0 { + t.Fatalf("expected empty signature, got %x", sig) + } +} + +func TestNoOpSignatureVerifier_AcceptsEverything(t *testing.T) { + v := NoOpSignatureVerifier() + if err := v.Verify([]byte("a"), []byte("b"), 1); err != nil { + t.Fatalf("NoOp must accept everything: %v", err) + } + if err := v.Verify(nil, nil, 1); err != nil { + t.Fatalf("NoOp must accept nil payload + nil sig: %v", err) + } +} + +func TestNoOpSigner_IsConcurrencySafe(t *testing.T) { + signer := NoOpSigner() + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 32; j++ { + if _, err := signer.Sign([]byte("payload")); err != nil { + t.Errorf("Sign error under concurrency: %v", err) + return + } + } + }() + } + wg.Wait() +} + +func TestCanonicalSnapshotBytes_ExcludesOperatorSignature(t *testing.T) { + snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 2, 3: 4}, + }) + withoutSig, err := snap.SignableBytes() + if err != nil { + t.Fatalf("canonical bytes (no sig): %v", err) + } + snap.OperatorSignature = []byte{0xff, 0xee} + withSig, err := snap.SignableBytes() + if err != nil { + t.Fatalf("canonical bytes (with sig): %v", err) + } + if !bytes.Equal(withoutSig, withSig) { + t.Fatalf( + "adding OperatorSignature changed canonical bytes; got %s vs %s", + string(withoutSig), string(withSig), + ) + } +} + +func TestCanonicalSnapshotBytes_RejectsNil(t *testing.T) { + if _, err := (*LocalEvidenceSnapshot)(nil).SignableBytes(); err == nil { + t.Fatal("expected error for nil snapshot") + } +} + +func TestBundleSignableBytes_ExcludeCoordinatorSignatureButIncludeSnapshotSignatures(t *testing.T) { + // Two fresh messages identical except for the coordinator signature + // must sign over the same bytes (the signature is over the body, not + // part of it). Fresh messages are required because signable bytes are + // computed once and cached. + msgA := buildValidTransitionMessage() + msgA.CoordinatorSignature = bytes.Repeat([]byte{0xaa}, 64) + msgB := buildValidTransitionMessage() + msgB.CoordinatorSignature = bytes.Repeat([]byte{0xbb}, 64) + bytesA, err := msgA.SignableBytes() + if err != nil { + t.Fatalf("signable bundle A: %v", err) + } + bytesB, err := msgB.SignableBytes() + if err != nil { + t.Fatalf("signable bundle B: %v", err) + } + if !bytes.Equal(bytesA, bytesB) { + t.Fatal("coordinator signature leaked into the signed bundle body") + } + + // A distinctive per-snapshot operator signature must appear verbatim + // inside the signed bundle body: the coordinator attests to the exact + // signed snapshot envelopes. + distinctive := bytes.Repeat([]byte{0xc7, 0x3d}, 16) + msgC := buildValidTransitionMessage() + msgC.Bundle[0].OperatorSignature = distinctive + bytesC, err := msgC.SignableBytes() + if err != nil { + t.Fatalf("signable bundle C: %v", err) + } + if !bytes.Contains(bytesC, distinctive) { + t.Fatal("per-snapshot operator signature missing from signed bundle body") + } + if bytes.Equal(bytesA, bytesC) { + t.Fatal("changing a snapshot operator signature must change the bundle body") + } +} + +func TestCanonicalBundleBytes_RejectsNil(t *testing.T) { + if _, err := (*TransitionMessage)(nil).SignableBytes(); err == nil { + t.Fatal("expected error for nil message") + } +} + +func TestVerifySnapshotSignature_RoundTripsThroughFakeSignerVerifier(t *testing.T) { + signer := &fakeSigner{id: 7} + snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + payload, err := snap.SignableBytes() + if err != nil { + t.Fatalf("canonical: %v", err) + } + sig, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + snap.OperatorSignature = sig + if err := verifySnapshotSignature(fakeVerifier{}, snap); err != nil { + t.Fatalf("expected valid signature, got %v", err) + } +} + +func TestVerifySnapshotSignature_RejectsMissingSignature(t *testing.T) { + snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + err := verifySnapshotSignature(fakeVerifier{}, snap) + if !errors.Is(err, ErrSignatureMissing) { + t.Fatalf("expected ErrSignatureMissing, got %v", err) + } +} + +func TestVerifySnapshotSignature_RejectsTamperedPayload(t *testing.T) { + signer := &fakeSigner{id: 7} + snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + payload, _ := snap.SignableBytes() + sig, _ := signer.Sign(payload) + // Attach the signature to a *different* snapshot (fresh struct, so + // no cached bytes): its signed body differs, so verification over + // its bytes must fail. + tampered := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{99: 1}, + }) + tampered.OperatorSignature = sig + if err := verifySnapshotSignature(fakeVerifier{}, tampered); !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("expected ErrSignatureInvalid, got %v", err) + } +} + +func TestVerifyBundleSignature_RoundTrip(t *testing.T) { + signer := &fakeSigner{id: 11} + msg := buildValidTransitionMessage() + msg.CoordinatorIDValue = 11 + msg.CoordinatorSignature = nil + payload, _ := msg.SignableBytes() + sig, _ := signer.Sign(payload) + msg.CoordinatorSignature = sig + if err := verifyBundleSignature(fakeVerifier{}, msg, 11); err != nil { + t.Fatalf("expected verified, got %v", err) + } +} + +func TestVerifyBundleSignature_RejectsCoordinatorMismatch(t *testing.T) { + msg := buildValidTransitionMessage() + msg.CoordinatorIDValue = 1 + msg.CoordinatorSignature = []byte{0x01} + err := verifyBundleSignature(fakeVerifier{}, msg, 99) + if err == nil { + t.Fatal("expected coordinator mismatch error") + } +} + +func TestVerifyOwnObservationsPresent_RequiresIdenticalSignature(t *testing.T) { + selfSubmission := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + selfSubmission.OperatorSignature = []byte{0xab} + bundle := &TransitionMessage{ + Bundle: []LocalEvidenceSnapshot{ + func() LocalEvidenceSnapshot { + s := *selfSubmission + s.OperatorSignature = []byte{0xff} + return s + }(), + }, + } + if err := verifyOwnObservationsPresent(bundle, 7, selfSubmission); !errors.Is(err, ErrCensorshipDetected) { + t.Fatalf("expected ErrCensorshipDetected on mutated sig, got %v", err) + } +} + +func TestVerifyOwnObservationsPresent_DetectsMissingSnapshot(t *testing.T) { + selfSubmission := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + bundle := &TransitionMessage{ + Bundle: []LocalEvidenceSnapshot{ + *NewLocalEvidenceSnapshot(8, pinnedContextHash, attempt.Evidence{}), + }, + } + if err := verifyOwnObservationsPresent(bundle, 7, selfSubmission); !errors.Is(err, ErrCensorshipDetected) { + t.Fatalf("expected ErrCensorshipDetected, got %v", err) + } +} + +func TestVerifyOwnObservationsPresent_SkipsWhenSelfZero(t *testing.T) { + bundle := &TransitionMessage{Bundle: []LocalEvidenceSnapshot{}} + if err := verifyOwnObservationsPresent(bundle, 0, nil); err != nil { + t.Fatalf("expected skip, got %v", err) + } +} + +func TestVerifyOwnObservationsPresent_SkipsWhenNoSelfSubmission(t *testing.T) { + bundle := &TransitionMessage{Bundle: []LocalEvidenceSnapshot{}} + if err := verifyOwnObservationsPresent(bundle, 7, nil); err != nil { + t.Fatalf("expected skip when no self submission, got %v", err) + } +} diff --git a/pkg/frost/roast/signing_package.go b/pkg/frost/roast/signing_package.go new file mode 100644 index 0000000000..1b41693bf9 --- /dev/null +++ b/pkg/frost/roast/signing_package.go @@ -0,0 +1,407 @@ +package roast + +import ( + "crypto/sha256" + "errors" + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// SignedSigningPackageType is the stable Type() string for the +// coordinator-distributed, operator-signed signing package (Phase 7.2b). +const SignedSigningPackageType = roastMessageTypePrefix + "signed_signing_package" + +// signingPackageSignatureDomain is the fixed domain-separation tag prefixed +// to the bytes the coordinator signs (see SignableBytes). The elected +// coordinator's operator key also signs TransitionMessage and evidence-snapshot +// bodies, and a SigningPackageBody is wire-compatible with a +// TransitionMessageBody (matching tags/types for attempt_context_hash and +// coordinator_id, and a length-delimited field 3), so the signed byte streams +// must not be confusable. +// +// The tag BEGINS with byte 0x00 - an illegal protobuf tag (field number 0) - +// which separates the domains in BOTH directions without relying on field +// layout: +// +// - A signing-package signature cannot be accepted on another envelope: +// presenting these signed bytes as that envelope's body fails when the +// receiver decodes the body, because every signed-body decoder +// proto.Unmarshals it and an illegal leading tag is rejected. (A +// valid-protobuf ASCII tag does NOT give this: a parser skips it as an +// unknown length-delimited field and resumes into a transition body +// crafted inside signing_package.) +// - Another message's signature cannot be accepted on a signing package: +// the signature is verified over signingPackageSignatureDomain || body, +// which begins with 0x00, whereas a serialized protobuf body always begins +// with a valid field tag (>= 0x08), so the two signed byte streams differ +// in their first byte and the signature cannot verify. +// +// The tag is NOT carried on the wire - it is a fixed constant both signer and +// verifier prepend. +var signingPackageSignatureDomain = []byte("\x00roast/signed-signing-package/v1\x00") + +// MaxSigningPackageBytes caps the embedded FROST SigningPackage length, +// rejecting pathological payloads at Unmarshal time so a misbehaving +// coordinator cannot exhaust receiver memory. Sized for a worst-case +// production signing subset's round-1 commitments plus generous headroom. +const MaxSigningPackageBytes = 1 << 20 // 1 MiB + +// TaprootMerkleRootLength is the byte length of a taproot script-tree +// root. A SigningPackage carries either exactly this many bytes or none +// (the key-path case). +const TaprootMerkleRootLength = 32 + +// MaxSignedSigningPackageBytes bounds a whole SignedSigningPackage envelope so +// Unmarshal can reject a grossly oversized message before proto.Unmarshal +// materializes it (and before the body/field copies). Sized as the +// signing-package cap plus the coordinator-signature cap plus generous +// framing/field overhead, so a legitimate maximum-size package still fits. +const MaxSignedSigningPackageBytes = MaxSigningPackageBytes + MaxCoordinatorSignatureBytes + 512 + +// SigningPackage is the coordinator-distributed signing package for one +// attempt, carried as a signed-body envelope (Phase 7.2b, frozen spec +// section 6). The elected coordinator signs the exact serialized +// SigningPackageBody with its operator key and distributes the +// SignedSigningPackage to the chosen signing subset; each member verifies +// the coordinator signature over the exact bytes it received and only then +// parses them - the same sign-what-you-transmit / verify-what-you-received +// discipline as the evidence envelopes (wire.go). +// +// This file defines the wire type and its byte-preservation contract only. +// Coordinator-side signing/distribution and member-side authentication +// (elected-coordinator check, signature verification, root binding) and +// retention land in later Phase 7.2b increments; the engine never sees this +// envelope (frozen spec: blame adjudication is Go-side). +type SigningPackage struct { + // AttemptContextHash binds the package to one attempt. Always exactly + // 32 bytes (attempt.MessageDigestLength). + AttemptContextHash []byte + // CoordinatorIDValue is the elected coordinator's member index + // (RFC-21 Annex A). A member authenticates the envelope by checking + // this equals the attempt's elected coordinator and that the + // signature verifies under that coordinator's operator key. + CoordinatorIDValue uint32 + // SigningPackageBytes is the serialized FROST SigningPackage the + // chosen subset signs over. + SigningPackageBytes []byte + // TaprootMerkleRoot is the taproot script-tree root the signature is + // tweaked by: exactly 32 bytes, or empty for a key-path spend. + TaprootMerkleRoot []byte + // SignerIDsValue is the wire (uint32) form of the chosen signing subset's + // member indices the FROST SigningPackageBytes was built over (RFC-21 Phase + // 7.3 t-of-included finalize): ascending, distinct, each a valid member index. + // It lets non-coordinators know which members to await round-2 shares from when + // the package covers a t-subset of the included set. The SigningPackageBytes is + // the cryptographic source of truth, so a coordinator that lies here causes + // only a liveness failure (aggregate fails closed), never a wrong signature or + // false blame. Empty for the full-included flow. Use SignerIDs() for the + // validated member-index form. + SignerIDsValue []uint32 + // CoordinatorSignature is the elected coordinator's operator-key + // signature over SignableBytes(). + CoordinatorSignature []byte + + // bodyCache caches the exact serialized SigningPackageBody: marshaled + // once at signing time for a self-authored package, or the received body + // bytes verbatim for a parsed one. This is the body field carried on the + // wire; fields must not be mutated once set. + bodyCache []byte + // signaturePayloadCache caches the exact bytes the CoordinatorSignature + // covers - the domain tag followed by the body (see SignableBytes). + signaturePayloadCache []byte + // wireEnvelope caches the exact on-wire envelope (body + signature): + // the received bytes verbatim for parsed packages, or built once + // after signing for self-authored ones. + wireEnvelope []byte +} + +func signingPackageBodyMessage(p *SigningPackage) *pb.SigningPackageBody { + return &pb.SigningPackageBody{ + AttemptContextHash: p.AttemptContextHash, + CoordinatorId: p.CoordinatorIDValue, + SigningPackage: p.SigningPackageBytes, + TaprootMerkleRoot: p.TaprootMerkleRoot, + SignerIds: p.SignerIDsValue, + } +} + +func signingPackageFieldsFromBody(p *SigningPackage, body *pb.SigningPackageBody) { + p.AttemptContextHash = append([]byte(nil), body.AttemptContextHash...) + p.CoordinatorIDValue = body.CoordinatorId + p.SigningPackageBytes = append([]byte(nil), body.SigningPackage...) + p.TaprootMerkleRoot = append([]byte(nil), body.TaprootMerkleRoot...) + p.SignerIDsValue = append([]uint32(nil), body.SignerIds...) +} + +// SignableBytes returns the exact byte stream the CoordinatorSignature +// covers: the signing-package domain tag (see signingPackageSignatureDomain) +// followed by the serialized SigningPackageBody. The domain tag is a fixed +// constant prepended by both signer and verifier and is NOT carried on the +// wire - it domain-separates this signature from the coordinator's +// transition-message signatures (whose body is otherwise wire-compatible). +// The body half is the bytes the package transmits: marshaled once for a +// self-authored package, or the received body verbatim for a parsed one +// (verify exactly what was received). Fields must not be mutated afterwards, +// and the returned slice is the internal cache - callers must not mutate it. +func (p *SigningPackage) SignableBytes() ([]byte, error) { + if p == nil { + return nil, errors.New("roast: cannot encode a nil signing package") + } + if p.signaturePayloadCache != nil { + return p.signaturePayloadCache, nil + } + body, err := p.bodyBytes() + if err != nil { + return nil, err + } + payload := make([]byte, 0, len(signingPackageSignatureDomain)+len(body)) + payload = append(payload, signingPackageSignatureDomain...) + payload = append(payload, body...) + p.signaturePayloadCache = payload + return payload, nil +} + +// bodyBytes returns the exact serialized SigningPackageBody - the body field +// carried in the SignedSigningPackage envelope. Marshaled once and cached for +// a self-authored package; the received bytes verbatim for a parsed one. The +// returned slice is the internal cache - callers must not mutate it. +func (p *SigningPackage) bodyBytes() ([]byte, error) { + if p == nil { + return nil, errors.New("roast: cannot encode a nil signing package") + } + if p.bodyCache != nil { + return p.bodyCache, nil + } + body, err := proto.Marshal(signingPackageBodyMessage(p)) + if err != nil { + return nil, fmt.Errorf("roast: marshal signing package body: %w", err) + } + p.bodyCache = body + return body, nil +} + +// Type implements net.TaggedUnmarshaler. +func (p *SigningPackage) Type() string { + return SignedSigningPackageType +} + +// Marshal serialises the package as a SignedSigningPackage envelope: the +// serialized SigningPackageBody plus the coordinator signature (which covers +// the domain-tagged body, see SignableBytes). For a package parsed off the +// wire the received envelope is returned verbatim, so the bytes a member +// retains for the section-3 equivocation comparison are the exact bytes it +// received. The package must be signed first. The returned slice is the +// internal cache - callers must not mutate it. +func (p *SigningPackage) Marshal() ([]byte, error) { + if p == nil { + return nil, errors.New("roast: cannot marshal a nil signing package") + } + if p.wireEnvelope != nil { + return p.wireEnvelope, nil + } + if len(p.CoordinatorSignature) == 0 { + return nil, errors.New( + "roast: signing package must be signed before wire encoding", + ) + } + body, err := p.bodyBytes() + if err != nil { + return nil, err + } + envelope, err := proto.Marshal(&pb.SignedSigningPackage{ + Body: body, + CoordinatorSignature: p.CoordinatorSignature, + }) + if err != nil { + return nil, fmt.Errorf("roast: marshal signing package envelope: %w", err) + } + p.wireEnvelope = envelope + return envelope, nil +} + +// BodyHash returns the SHA-256 of the package's signed body bytes - the value a +// ShareSubmission commits to in signing_package_hash, and the identity used to +// detect coordinator equivocation. It hashes the BODY (the serialized +// SigningPackageBody the coordinator signs), NOT the on-wire envelope: the +// coordinator signature does not cover the outer envelope, so an unsigned +// re-encoding of the same (body, signature) would change an envelope hash +// without being equivocation, and would fragment share bindings across members. +// The body bytes are stable - any re-serialization of the body would fail +// signature verification - so honest envelope re-encodings map to the same +// BodyHash. For a package parsed off the wire this hashes the received body +// verbatim. +func (p *SigningPackage) BodyHash() ([sha256.Size]byte, error) { + body, err := p.bodyBytes() + if err != nil { + return [sha256.Size]byte{}, err + } + return sha256.Sum256(body), nil +} + +// Unmarshal parses a SignedSigningPackage envelope, retains the received +// body and envelope bytes verbatim (the coordinator signature is verified +// over exactly these bytes), populates the fields from the body, and +// validates the structure. +func (p *SigningPackage) Unmarshal(data []byte) error { + if p == nil { + return errors.New("roast: cannot unmarshal into a nil signing package") + } + // Bound the input before allocating: reject a grossly oversized envelope + // before proto.Unmarshal materializes it (and before the copies below), so + // the MaxSigningPackageBytes cap protects memory rather than only rejecting + // after the fact. + if len(data) > MaxSignedSigningPackageBytes { + return fmt.Errorf( + "signed signing package: envelope length [%d] exceeds cap [%d]", + len(data), + MaxSignedSigningPackageBytes, + ) + } + var envelope pb.SignedSigningPackage + if err := proto.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("signed signing package: parse envelope: %w", err) + } + if len(envelope.Body) == 0 { + return errors.New("signed signing package: empty body") + } + var body pb.SigningPackageBody + if err := proto.Unmarshal(envelope.Body, &body); err != nil { + return fmt.Errorf("signed signing package: parse body: %w", err) + } + // Enforce the signing-package cap on the parsed field before copying it + // into the struct (and before caching the body), so an over-cap field is + // rejected without the extra allocations. + if len(body.SigningPackage) > MaxSigningPackageBytes { + return fmt.Errorf( + "signed signing package: signingPackage length [%d] exceeds cap [%d]", + len(body.SigningPackage), + MaxSigningPackageBytes, + ) + } + signingPackageFieldsFromBody(p, &body) + p.CoordinatorSignature = append([]byte(nil), envelope.CoordinatorSignature...) + p.bodyCache = append([]byte(nil), envelope.Body...) + p.wireEnvelope = append([]byte(nil), data...) + // Prime the signable-bytes cache from the body just received, discarding any + // cache a prior SignableBytes call left on a reused value. Priming here - + // rather than lazily in SignableBytes - keeps concurrent signature + // verification of a parsed package race-free: verifiers read a ready cache + // instead of racing on lazy initialization (authentication must verify + // against the received bytes, never stale ones). + p.signaturePayloadCache = nil + if _, err := p.SignableBytes(); err != nil { + return err + } + return p.Validate() +} + +// AttemptContextHashArray returns the attempt context hash as a fixed +// 32-byte array. Validate (or Unmarshal) must have confirmed the length +// first; it copies at most 32 bytes and zero-pads a short slice. +func (p *SigningPackage) AttemptContextHashArray() [attempt.MessageDigestLength]byte { + var out [attempt.MessageDigestLength]byte + copy(out[:], p.AttemptContextHash) + return out +} + +// CoordinatorID returns the elected coordinator's member index. +func (p *SigningPackage) CoordinatorID() group.MemberIndex { + return group.MemberIndex(p.CoordinatorIDValue) +} + +// Validate runs the structural checks Unmarshal applies after a decode. +// Exposed so callers that construct packages in memory (e.g. the +// coordinator) can validate without a marshal/unmarshal round-trip. It +// does not verify the coordinator signature - that is the member-side +// authentication step (a later Phase 7.2b increment), which checks the +// signature against the attempt's elected coordinator's operator key. +func (p *SigningPackage) Validate() error { + if p == nil { + return errors.New("signed signing package: nil") + } + if len(p.AttemptContextHash) != attempt.MessageDigestLength { + return fmt.Errorf( + "signed signing package: attemptContextHash length [%d], expected [%d]", + len(p.AttemptContextHash), + attempt.MessageDigestLength, + ) + } + if p.CoordinatorIDValue == 0 { + return errors.New("signed signing package: coordinatorID is zero") + } + // coordinator_id is a wire uint32 but a member index is a uint8 + // (group.MemberIndex); reject an out-of-range value here so CoordinatorID() + // never silently truncates and the member-side elected-coordinator check + // compares a faithful value. + if p.CoordinatorIDValue > group.MaxMemberIndex { + return fmt.Errorf( + "signed signing package: coordinatorID [%d] exceeds max member index [%d]", + p.CoordinatorIDValue, + group.MaxMemberIndex, + ) + } + if len(p.SigningPackageBytes) == 0 { + return errors.New("signed signing package: empty signing package") + } + if len(p.SigningPackageBytes) > MaxSigningPackageBytes { + return fmt.Errorf( + "signed signing package: signingPackage length [%d] exceeds cap [%d]", + len(p.SigningPackageBytes), + MaxSigningPackageBytes, + ) + } + if n := len(p.TaprootMerkleRoot); n != 0 && n != TaprootMerkleRootLength { + return fmt.Errorf( + "signed signing package: taprootMerkleRoot length [%d], expected 0 (key-path) or %d", + n, + TaprootMerkleRootLength, + ) + } + if len(p.CoordinatorSignature) > MaxCoordinatorSignatureBytes { + return fmt.Errorf( + "signed signing package: coordinatorSignature length [%d] exceeds cap [%d]", + len(p.CoordinatorSignature), + MaxCoordinatorSignatureBytes, + ) + } + // signer_ids (when present) names the chosen signing subset: each a real member + // index (so SignerIDs() never truncates) and STRICTLY ASCENDING (hence + // distinct, and bounded to <= the member-index space). Empty is valid -- the + // full-included flow carries no subset. This is a structural/liveness check: + // the engine verifies shares against the SigningPackageBytes (the cryptographic + // source of truth), so a lying list only fails an attempt, never produces a + // wrong signature or false blame. + for i, id := range p.SignerIDsValue { + if id == 0 || id > group.MaxMemberIndex { + return fmt.Errorf( + "signed signing package: signerID [%d] is not a valid member index", + id, + ) + } + if i > 0 && id <= p.SignerIDsValue[i-1] { + return fmt.Errorf( + "signed signing package: signerIDs must be strictly ascending (got [%d] after [%d])", + id, p.SignerIDsValue[i-1], + ) + } + } + return nil +} + +// SignerIDs returns the chosen signing subset's member indices in their validated +// form. Callers MUST Validate the package first (AuthenticateSigningPackage does): +// Validate guarantees each value is a real member index, so the uint32 -> +// group.MemberIndex conversion here cannot truncate. Returns an empty slice when +// the package carries no subset (the full-included flow). +func (p *SigningPackage) SignerIDs() []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(p.SignerIDsValue)) + for _, id := range p.SignerIDsValue { + out = append(out, group.MemberIndex(id)) + } + return out +} diff --git a/pkg/frost/roast/signing_package_auth.go b/pkg/frost/roast/signing_package_auth.go new file mode 100644 index 0000000000..eb0de66960 --- /dev/null +++ b/pkg/frost/roast/signing_package_auth.go @@ -0,0 +1,119 @@ +package roast + +import ( + "bytes" + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ErrSigningPackageWrongCoordinator is returned by AuthenticateSigningPackage +// when a signed signing package names a coordinator other than the attempt's +// elected coordinator (RFC-21 Annex A). attempt_context_hash is public, so any +// operator could sign a body carrying it; an envelope from a non-elected +// coordinator is not attributable to the coordinator and MUST NOT be retained. +var ErrSigningPackageWrongCoordinator = errors.New( + "roast: signing package coordinator is not the attempt's elected coordinator", +) + +// ErrSigningPackageWrongAttempt is returned when a signed signing package's +// attempt_context_hash does not match the live attempt. +var ErrSigningPackageWrongAttempt = errors.New( + "roast: signing package attempt context hash does not match the live attempt", +) + +// SignSigningPackage signs pkg with the elected coordinator's operator key, +// setting pkg.CoordinatorSignature over pkg.SignableBytes() (the domain-tagged +// body). The elected coordinator calls this before distributing the +// SignedSigningPackage to the chosen signing subset. pkg must be structurally +// valid (call Validate first). +func SignSigningPackage(signer Signer, pkg *SigningPackage) error { + payload, err := pkg.SignableBytes() + if err != nil { + return err + } + signature, err := signer.Sign(payload) + if err != nil { + return fmt.Errorf("roast: sign signing package: %w", err) + } + pkg.CoordinatorSignature = signature + return nil +} + +// AuthenticateSigningPackage verifies that pkg is genuine evidence from the +// attempt's elected coordinator: it names electedCoordinator, its signature +// verifies under that coordinator's operator key over the domain-tagged body, +// and its attempt_context_hash matches the live attempt. (electedCoordinator +// is resolved by the caller from the attempt via SelectCoordinator, exactly as +// Coordinator.VerifyBundle resolves the bundle coordinator.) +// +// A package that passes is attributable to the coordinator, so the member MUST +// retain its exact received bytes - the section-3 cross-member equivocation +// comparison needs them - BEFORE deciding whether to sign over it. A package +// that fails any check is forgeable noise: the caller rejects it WITHOUT +// retaining it. +// +// This deliberately does NOT check the taproot root. Root binding is the +// separate sign/no-sign decision (see MatchesRoot): a root-divergent but +// genuine-coordinator envelope is still retained as equivocation evidence and +// then refused, so root verification must not gate retention here. +func AuthenticateSigningPackage( + verifier SignatureVerifier, + pkg *SigningPackage, + electedCoordinator group.MemberIndex, + liveAttemptContextHash []byte, +) error { + // Structurally validate first (authentication boundary): reject a + // manually-assembled package before the truncating ID accessor or bytes.Equal + // checks below trust any field - e.g. a coordinator_id that truncates to the + // elected member (uint32 -> uint8). Mirrors AuthenticateShareSubmission. + if err := pkg.Validate(); err != nil { + return fmt.Errorf("signing package failed structural validation: %w", err) + } + if len(pkg.CoordinatorSignature) == 0 { + return fmt.Errorf( + "%w: signing package has no coordinator signature", + ErrSignatureMissing, + ) + } + if pkg.CoordinatorID() != electedCoordinator { + return fmt.Errorf( + "%w: package coordinator %d, elected %d", + ErrSigningPackageWrongCoordinator, + pkg.CoordinatorID(), + electedCoordinator, + ) + } + if !bytes.Equal(pkg.AttemptContextHash, liveAttemptContextHash) { + return ErrSigningPackageWrongAttempt + } + payload, err := pkg.SignableBytes() + if err != nil { + return fmt.Errorf("signing package signable bytes: %w", err) + } + if err := verifier.Verify( + payload, + pkg.CoordinatorSignature, + pkg.CoordinatorID(), + ); err != nil { + return fmt.Errorf( + "%w: coordinator %d: %s", + ErrSignatureInvalid, + pkg.CoordinatorID(), + err.Error(), + ) + } + return nil +} + +// MatchesRoot reports whether the package's taproot_merkle_root equals the +// live session/signing root (both empty for a key-path spend). After +// authenticating and retaining a package, a member signs over its +// signing_package ONLY when this is true: a divergent root means the +// coordinator is committing the subset to a tweaked key other than the +// session's, so the member refuses to sign and the retained envelope stands as +// root-equivocation evidence for the section-3 comparison. +func (p *SigningPackage) MatchesRoot(liveRoot []byte) bool { + return bytes.Equal(p.TaprootMerkleRoot, liveRoot) +} diff --git a/pkg/frost/roast/signing_package_auth_test.go b/pkg/frost/roast/signing_package_auth_test.go new file mode 100644 index 0000000000..2f90f8e6b4 --- /dev/null +++ b/pkg/frost/roast/signing_package_auth_test.go @@ -0,0 +1,151 @@ +package roast + +import ( + "bytes" + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const testElectedCoordinator = group.MemberIndex(3) + +func TestSignSigningPackage_RoundTripAuthenticates(t *testing.T) { + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: uint32(testElectedCoordinator), + SigningPackageBytes: []byte("frost-signing-package-bytes"), + } + if err := SignSigningPackage(&fakeSigner{id: testElectedCoordinator}, pkg); err != nil { + t.Fatalf("sign: %v", err) + } + if len(pkg.CoordinatorSignature) == 0 { + t.Fatal("SignSigningPackage must set a coordinator signature") + } + + // A member receives the envelope off the wire and authenticates it as + // genuine evidence from the attempt's elected coordinator. + wire, err := pkg.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + var received SigningPackage + if err := received.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := AuthenticateSigningPackage( + fakeVerifier{}, + &received, + testElectedCoordinator, + pinnedContextHash[:], + ); err != nil { + t.Fatalf("authenticate a genuine package: %v", err) + } +} + +func TestAuthenticateSigningPackage_Rejections(t *testing.T) { + signed := func() *SigningPackage { + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: uint32(testElectedCoordinator), + SigningPackageBytes: []byte("pkg"), + } + if err := SignSigningPackage(&fakeSigner{id: testElectedCoordinator}, pkg); err != nil { + t.Fatalf("sign: %v", err) + } + return pkg + } + otherAttempt := bytes.Repeat([]byte{0x09}, attempt.MessageDigestLength) + + t.Run("missing signature is rejected", func(t *testing.T) { + pkg := signed() + pkg.CoordinatorSignature = nil + err := AuthenticateSigningPackage(fakeVerifier{}, pkg, testElectedCoordinator, pinnedContextHash[:]) + if !errors.Is(err, ErrSignatureMissing) { + t.Fatalf("want ErrSignatureMissing, got %v", err) + } + }) + + t.Run("non-elected coordinator is rejected without retention", func(t *testing.T) { + err := AuthenticateSigningPackage(fakeVerifier{}, signed(), testElectedCoordinator+1, pinnedContextHash[:]) + if !errors.Is(err, ErrSigningPackageWrongCoordinator) { + t.Fatalf("want ErrSigningPackageWrongCoordinator, got %v", err) + } + }) + + t.Run("wrong attempt context is rejected", func(t *testing.T) { + err := AuthenticateSigningPackage(fakeVerifier{}, signed(), testElectedCoordinator, otherAttempt) + if !errors.Is(err, ErrSigningPackageWrongAttempt) { + t.Fatalf("want ErrSigningPackageWrongAttempt, got %v", err) + } + }) + + t.Run("tampered signature fails verification", func(t *testing.T) { + pkg := signed() + pkg.CoordinatorSignature[0] ^= 0xff + err := AuthenticateSigningPackage(fakeVerifier{}, pkg, testElectedCoordinator, pinnedContextHash[:]) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("want ErrSignatureInvalid, got %v", err) + } + }) + + t.Run("package signed by a non-elected operator is rejected", func(t *testing.T) { + // A non-elected operator signs a body carrying the elected + // coordinator's id (attempt_context_hash is public, so it can). The + // signature does not verify under the elected coordinator's key. + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: uint32(testElectedCoordinator), + SigningPackageBytes: []byte("pkg"), + } + if err := SignSigningPackage(&fakeSigner{id: testElectedCoordinator + 7}, pkg); err != nil { + t.Fatalf("sign: %v", err) + } + err := AuthenticateSigningPackage(fakeVerifier{}, pkg, testElectedCoordinator, pinnedContextHash[:]) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("want ErrSignatureInvalid, got %v", err) + } + }) +} + +func TestSigningPackage_MatchesRoot(t *testing.T) { + root := bytes.Repeat([]byte{0xab}, TaprootMerkleRootLength) + other := bytes.Repeat([]byte{0xcd}, TaprootMerkleRootLength) + keyPath := &SigningPackage{} + scriptPath := &SigningPackage{TaprootMerkleRoot: root} + + if !keyPath.MatchesRoot(nil) { + t.Fatal("a key-path package must match an empty live root") + } + if keyPath.MatchesRoot(root) { + t.Fatal("a key-path package must not match a script-path live root") + } + if !scriptPath.MatchesRoot(root) { + t.Fatal("a script-path package must match its own root") + } + if scriptPath.MatchesRoot(nil) { + t.Fatal("a script-path package must not match an empty (key-path) live root") + } + if scriptPath.MatchesRoot(other) { + t.Fatal("a script-path package must not match a divergent root") + } +} + +func TestAuthenticateSigningPackage_RejectsStructurallyInvalid(t *testing.T) { + // Authentication is a boundary for untrusted input: a manually-assembled + // package that did not pass Unmarshal/Validate - here a coordinator_id that + // truncates to the elected member (uint32 -> uint8) - must be rejected + // before verification. + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 259, // truncates to member 3 + SigningPackageBytes: []byte("pkg"), + } + if err := SignSigningPackage(&fakeSigner{id: 3}, pkg); err != nil { + t.Fatalf("sign: %v", err) + } + if err := AuthenticateSigningPackage(fakeVerifier{}, pkg, 3, pinnedContextHash[:]); err == nil { + t.Fatal("an out-of-range coordinator_id must be rejected before verification") + } +} diff --git a/pkg/frost/roast/signing_package_test.go b/pkg/frost/roast/signing_package_test.go new file mode 100644 index 0000000000..bb3ec5659d --- /dev/null +++ b/pkg/frost/roast/signing_package_test.go @@ -0,0 +1,410 @@ +package roast + +import ( + "bytes" + "sync" + "testing" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// These pin the signed-body envelope contract for the coordinator's signing +// package: the coordinator signs exactly the bytes that travel, those bytes +// survive re-broadcast verbatim, and parsing never depends on a serializer's +// canonical form. Coordinator-signature verification and member-side +// authentication arrive with a later Phase 7.2b increment. + +func signedTestSigningPackage( + t *testing.T, + coordinator group.MemberIndex, + root []byte, +) *SigningPackage { + t.Helper() + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: uint32(coordinator), + SigningPackageBytes: []byte("frost-signing-package-bytes"), + TaprootMerkleRoot: root, + } + payload, err := pkg.SignableBytes() + if err != nil { + t.Fatalf("signable bytes: %v", err) + } + sig, err := (&fakeSigner{id: coordinator}).Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + pkg.CoordinatorSignature = sig + return pkg +} + +func TestSigningPackageWire_ReceivedBytesPreservedVerbatim(t *testing.T) { + original := signedTestSigningPackage(t, 3, nil) + wire, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + decoded := &SigningPackage{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + rebroadcast, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(rebroadcast, wire) { + t.Fatal("re-marshal of a received signing package must return the received bytes verbatim") + } + + producerBody, _ := original.SignableBytes() + receiverBody, _ := decoded.SignableBytes() + if !bytes.Equal(producerBody, receiverBody) { + t.Fatal("receiver must be able to verify over exactly the bytes the coordinator signed") + } + if decoded.CoordinatorIDValue != original.CoordinatorIDValue || + !bytes.Equal(decoded.AttemptContextHash, original.AttemptContextHash) || + !bytes.Equal(decoded.SigningPackageBytes, original.SigningPackageBytes) || + !bytes.Equal(decoded.CoordinatorSignature, original.CoordinatorSignature) { + t.Fatal("decoded fields must match the original") + } +} + +func TestSigningPackageWire_NonCanonicalEnvelopeEncodingSurvives(t *testing.T) { + original := signedTestSigningPackage(t, 3, nil) + body, err := original.bodyBytes() + if err != nil { + t.Fatalf("body bytes: %v", err) + } + + // Handcraft an envelope with fields in REVERSE tag order + // (coordinator_signature before body) - wire-legal but non-canonical, no + // Go marshaler would emit it. Field 1 (body) tag 0x0a, field 2 + // (coordinator_signature) tag 0x12; both length-delimited. + var crafted []byte + crafted = append(crafted, 0x12, byte(len(original.CoordinatorSignature))) + crafted = append(crafted, original.CoordinatorSignature...) + crafted = append(crafted, 0x0a, byte(len(body))) + crafted = append(crafted, body...) + + var check pb.SignedSigningPackage + if err := proto.Unmarshal(crafted, &check); err != nil { + t.Fatalf("crafted envelope must be wire-legal: %v", err) + } + + decoded := &SigningPackage{} + if err := decoded.Unmarshal(crafted); err != nil { + t.Fatalf("unmarshal crafted: %v", err) + } + if gotBody, _ := decoded.bodyBytes(); !bytes.Equal(gotBody, body) { + t.Fatal("the received body must be preserved verbatim") + } + remarshaled, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(remarshaled, crafted) { + t.Fatal("re-marshal must preserve even a non-canonical received encoding verbatim") + } +} + +func TestSigningPackageWire_SignedBytesDomainSeparatedFromTransitionMessage(t *testing.T) { + pkg := signedTestSigningPackage(t, 3, nil) + signable, err := pkg.SignableBytes() + if err != nil { + t.Fatalf("signable: %v", err) + } + body, _ := pkg.bodyBytes() + + // The signed bytes are the domain tag followed by the body. + if !bytes.HasPrefix(signable, signingPackageSignatureDomain) { + t.Fatal("signed bytes must carry the signing-package domain tag") + } + if !bytes.Equal(signable[len(signingPackageSignatureDomain):], body) { + t.Fatal("signed bytes must be the domain tag followed by the body") + } + + // The bare body IS wire-compatible with a TransitionMessageBody - the + // collision the domain tag defends against: it presents the same 32-byte + // attempt_context_hash and coordinator_id a transition body would. + var asTransition pb.TransitionMessageBody + if err := proto.Unmarshal(body, &asTransition); err != nil { + t.Fatalf("the bare body is expected to parse as a TransitionMessageBody: %v", err) + } + if len(asTransition.AttemptContextHash) != attempt.MessageDigestLength || + asTransition.CoordinatorId != pkg.CoordinatorIDValue { + t.Fatal("sanity: the bare body must collide with TransitionMessageBody") + } + + // But the domain-tagged SIGNED bytes begin with an illegal protobuf tag + // (field 0), so they are not decodable as ANY protobuf message - a + // signing-package signature therefore cannot be replayed onto a + // transition-message (or other coordinator-signed) envelope, whose decoder + // proto.Unmarshals and rejects the body. A valid-protobuf tag would not give + // this (see TestSigningPackageWire_SignedBytesResistEmbeddedTransitionBody). + if signable[0] != 0x00 { + t.Fatal("signed bytes must begin with an illegal protobuf tag (0x00)") + } + if err := proto.Unmarshal(signable, &pb.TransitionMessageBody{}); err == nil { + t.Fatal("domain-tagged signed bytes must not decode as a protobuf message") + } +} + +func TestSigningPackageWire_SignedBytesResistEmbeddedTransitionBody(t *testing.T) { + // A malicious coordinator controls signing_package, so it can embed a fully + // valid serialized TransitionMessageBody there. Under a domain tag that is + // itself valid protobuf wire data, a parser skips the tag as an unknown + // field and could resume into this embedded transition body, re-enabling + // cross-protocol signature confusion. The leading illegal tag must make the + // whole signed payload undecodable regardless of the embedded content. + embeddedTransition, err := proto.Marshal(&pb.TransitionMessageBody{ + AttemptContextHash: bytes.Repeat([]byte{0x07}, attempt.MessageDigestLength), + CoordinatorId: 3, + }) + if err != nil { + t.Fatalf("marshal embedded transition: %v", err) + } + // Sanity: the embedded payload really is a valid transition body. + var sanity pb.TransitionMessageBody + if err := proto.Unmarshal(embeddedTransition, &sanity); err != nil || + len(sanity.AttemptContextHash) != attempt.MessageDigestLength { + t.Fatal("sanity: embedded payload must be a valid transition body") + } + + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 3, + SigningPackageBytes: embeddedTransition, + } + signable, err := pkg.SignableBytes() + if err != nil { + t.Fatalf("signable: %v", err) + } + if err := proto.Unmarshal(signable, &pb.TransitionMessageBody{}); err == nil { + t.Fatal("signed bytes embedding a transition body must still be undecodable as protobuf") + } +} + +func TestSigningPackageWire_UnmarshalResetsSignableCache(t *testing.T) { + // A SigningPackage value reused across a SignableBytes call and then an + // Unmarshal must authenticate the newly decoded package against the bytes it + // just received, never the stale cached payload. + reused := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 3, + SigningPackageBytes: []byte("stale-package"), + } + if _, err := reused.SignableBytes(); err != nil { // prime the cache + t.Fatalf("prime cache: %v", err) + } + + // Decode a different, genuine package into the SAME value. + genuine := signedTestSigningPackage(t, 5, bytes.Repeat([]byte{0xab}, TaprootMerkleRootLength)) + wire, err := genuine.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := reused.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal into reused value: %v", err) + } + + got, _ := reused.SignableBytes() + want, _ := genuine.SignableBytes() + if !bytes.Equal(got, want) { + t.Fatal("Unmarshal must reset the signable-bytes cache to the received body") + } + if err := AuthenticateSigningPackage(fakeVerifier{}, reused, 5, pinnedContextHash[:]); err != nil { + t.Fatalf("authenticate reused-decoded package: %v", err) + } +} + +func TestSigningPackageWire_SignerIDsRoundTripAndSigned(t *testing.T) { + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 3, + SigningPackageBytes: []byte("frost-signing-package-bytes"), + SignerIDsValue: []uint32{1, 2, 5}, + } + payload, err := pkg.SignableBytes() + if err != nil { + t.Fatalf("signable: %v", err) + } + pkg.CoordinatorSignature, err = (&fakeSigner{id: 3}).Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + + // signer_ids is part of the SIGNED body: a different list yields different + // signable bytes, so a coordinator cannot swap the subset without re-signing + // and the field is authenticated, not an unsigned sidecar. + other := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 3, + SigningPackageBytes: []byte("frost-signing-package-bytes"), + SignerIDsValue: []uint32{1, 2, 4}, + } + otherPayload, _ := other.SignableBytes() + if bytes.Equal(payload, otherPayload) { + t.Fatal("signer_ids must be covered by the signed body (different lists -> different signable bytes)") + } + + wire, err := pkg.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &SigningPackage{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := decoded.SignerIDs(); len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 5 { + t.Fatalf("signer_ids must round-trip into the validated member indices; got %v", got) + } + if err := AuthenticateSigningPackage(fakeVerifier{}, decoded, 3, pinnedContextHash[:]); err != nil { + t.Fatalf("authenticate a package carrying signer_ids: %v", err) + } + + // A package with no subset (the full-included flow) returns an empty, + // non-panicking SignerIDs() and still validates. + if ids := (&SigningPackage{}).SignerIDs(); len(ids) != 0 { + t.Fatalf("empty signer_ids must yield an empty SignerIDs(); got %v", ids) + } +} + +func TestSigningPackageWire_RootRoundTrips(t *testing.T) { + for _, tc := range []struct { + name string + root []byte + }{ + {"key-path (empty root)", nil}, + {"script-path (32-byte root)", bytes.Repeat([]byte{0xab}, TaprootMerkleRootLength)}, + } { + t.Run(tc.name, func(t *testing.T) { + wire, err := signedTestSigningPackage(t, 5, tc.root).Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &SigningPackage{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !bytes.Equal(decoded.TaprootMerkleRoot, tc.root) { + t.Fatalf("root mismatch: got %x want %x", decoded.TaprootMerkleRoot, tc.root) + } + }) + } +} + +func TestSigningPackage_ValidateRejectsMalformed(t *testing.T) { + valid := func() *SigningPackage { + return &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 3, + SigningPackageBytes: []byte("pkg"), + } + } + if err := valid().Validate(); err != nil { + t.Fatalf("a well-formed package must validate: %v", err) + } + for _, tc := range []struct { + name string + mutate func(*SigningPackage) + }{ + {"short attempt hash", func(p *SigningPackage) { p.AttemptContextHash = []byte{1, 2, 3} }}, + {"zero coordinator", func(p *SigningPackage) { p.CoordinatorIDValue = 0 }}, + {"coordinator out of member-index range", func(p *SigningPackage) { + p.CoordinatorIDValue = group.MaxMemberIndex + 1 + }}, + {"empty signing package", func(p *SigningPackage) { p.SigningPackageBytes = nil }}, + {"bad root length", func(p *SigningPackage) { p.TaprootMerkleRoot = []byte{0x01} }}, + {"oversize signing package", func(p *SigningPackage) { + p.SigningPackageBytes = make([]byte, MaxSigningPackageBytes+1) + }}, + {"signer id zero", func(p *SigningPackage) { p.SignerIDsValue = []uint32{1, 0, 3} }}, + {"signer id out of member-index range", func(p *SigningPackage) { + p.SignerIDsValue = []uint32{1, group.MaxMemberIndex + 1} + }}, + {"signer ids not ascending", func(p *SigningPackage) { p.SignerIDsValue = []uint32{3, 1} }}, + {"signer ids duplicate", func(p *SigningPackage) { p.SignerIDsValue = []uint32{2, 2} }}, + } { + t.Run(tc.name, func(t *testing.T) { + p := valid() + tc.mutate(p) + if err := p.Validate(); err == nil { + t.Fatal("expected Validate to reject the malformed package") + } + }) + } +} + +func TestSigningPackageWire_UnmarshalRejectsOversizeBeforeCopy(t *testing.T) { + // A peer-supplied envelope whose signing_package exceeds the cap is + // rejected on receive, so the cap protects memory rather than only + // failing after the field is materialized and copied. + oversized := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 3, + SigningPackageBytes: make([]byte, MaxSigningPackageBytes+1), + } + payload, err := oversized.SignableBytes() + if err != nil { + t.Fatalf("signable: %v", err) + } + sig, err := (&fakeSigner{id: 3}).Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + oversized.CoordinatorSignature = sig + wire, err := oversized.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var decoded SigningPackage + if err := decoded.Unmarshal(wire); err == nil { + t.Fatal("Unmarshal must reject an over-cap signing package") + } +} + +func TestSigningPackage_MarshalRequiresSignature(t *testing.T) { + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 3, + SigningPackageBytes: []byte("pkg"), + } + if _, err := pkg.Marshal(); err == nil { + t.Fatal("Marshal must refuse an unsigned signing package") + } +} + +func TestSigningPackage_ConcurrentSignableBytesAfterUnmarshalIsRaceFree(t *testing.T) { + // Regression guard (run under -race): a parsed signing package must carry a + // primed signable-bytes cache so concurrent signature verification reads a + // ready cache instead of racing on lazy initialization. Without priming in + // Unmarshal, the concurrent first SignableBytes calls below race on the + // cache write. + wire, err := signedTestSigningPackage(t, 3, nil).Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded SigningPackage + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := decoded.SignableBytes(); err != nil { + t.Errorf("signable: %v", err) + } + }() + } + wg.Wait() +} diff --git a/pkg/frost/roast/signing_retry_adapter.go b/pkg/frost/roast/signing_retry_adapter.go new file mode 100644 index 0000000000..491f5b9e5a --- /dev/null +++ b/pkg/frost/roast/signing_retry_adapter.go @@ -0,0 +1,142 @@ +package roast + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// MemberToParticipantResolver maps a ROAST group.MemberIndex to the +// participant-identifier type the legacy signing-retry path uses +// (typically chain.Address in keep-core production flows, but the +// interface is intentionally generic in T so pkg/frost/roast does +// not import any caller-side type). +// +// Implementations are wallet-scoped: each FROST signing flow +// constructs a resolver from its existing wallet/group state at the +// call site and passes it to EvaluateRoastRetryForSigning or +// SigningRetryAdapter. +type MemberToParticipantResolver[T any] interface { + // For returns the participant identifier corresponding to the + // given member index. Returns an error if the member is unknown + // to the resolver (out-of-range index, evicted member, etc.). + For(member group.MemberIndex) (T, error) +} + +// EvaluateRoastRetryForSigning bridges the ROAST coordinator state +// machine with the legacy signing-retry shape. Given the previous +// attempt's handle and a verified TransitionMessage, it computes +// the next attempt's IncludedSet, converts each member index to its +// resolver-supplied participant identifier, and returns both the +// participant list and the full AttemptContext. +// +// Callers MUST call Coordinator.VerifyBundle on bundle before +// passing it to this function; the bundle is the load-bearing +// authoritative input to NextAttempt and an unverified bundle would +// silently fracture multi-instance agreement. +// +// Returns ErrAttemptInfeasible directly when the next attempt's +// included set would drop below threshold; the caller must +// propagate that to the session manager rather than swallow it. +// See RFC-21 Phase-5 Resolved Decision on infeasibility. +// +// The function is generic in T so it can be used with chain.Address +// in production keep-core flows and with simple test types +// (strings, ints) in unit tests. +func EvaluateRoastRetryForSigning[T any]( + coord Coordinator, + handle AttemptHandle, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, + resolver MemberToParticipantResolver[T], +) ([]T, attempt.AttemptContext, error) { + if coord == nil { + return nil, attempt.AttemptContext{}, fmt.Errorf( + "roast retry adapter: coordinator is nil", + ) + } + if resolver == nil { + var zero T + _ = zero + return nil, attempt.AttemptContext{}, fmt.Errorf( + "roast retry adapter: resolver is nil", + ) + } + nextCtx, err := coord.NextAttempt(handle, bundle, threshold, dkgGroupPublicKey) + if err != nil { + return nil, attempt.AttemptContext{}, err + } + participants := make([]T, 0, len(nextCtx.IncludedSet)) + for _, m := range nextCtx.IncludedSet { + t, err := resolver.For(m) + if err != nil { + return nil, attempt.AttemptContext{}, fmt.Errorf( + "roast retry adapter: resolver failed for member %d: %w", + m, + err, + ) + } + participants = append(participants, t) + } + return participants, nextCtx, nil +} + +// SigningRetryAdapter binds the inputs to EvaluateRoastRetryForSigning +// onto a struct so call sites can hold the configuration once and +// call EvaluateRetryParticipantsForSigning (legacy-shaped) per +// retry. Phase 6 migrates call sites to either the function or the +// struct -- whichever fits the existing call shape. +type SigningRetryAdapter[T any] struct { + Coordinator Coordinator + Handle AttemptHandle + Bundle *TransitionMessage + Threshold uint + DkgGroupPublicKey []byte + Resolver MemberToParticipantResolver[T] +} + +// EvaluateRetryParticipantsForSigning matches the shape of the +// legacy helper in pkg/protocol/retry so call sites can adopt the +// adapter without changing their function-call surface. The legacy +// signature's parameters (groupMembers, seed, retryCount, +// retryParticipantsCount) are ignored: the AttemptContext bound to +// the handle is the source of truth for next-attempt selection. +// +// Returns the next IncludedSet's participants and any error from +// NextAttempt (typically ErrAttemptInfeasible). +func (a SigningRetryAdapter[T]) EvaluateRetryParticipantsForSigning( + _ []T, + _ int64, + _ uint, + _ uint, +) ([]T, error) { + participants, _, err := EvaluateRoastRetryForSigning( + a.Coordinator, + a.Handle, + a.Bundle, + a.Threshold, + a.DkgGroupPublicKey, + a.Resolver, + ) + return participants, err +} + +// NextAttemptContext returns the AttemptContext the adapter would +// transition to. Useful when callers need both the participant +// list and the context (e.g. to re-bind session orchestration to +// the new attempt's handle). +func (a SigningRetryAdapter[T]) NextAttemptContext() ( + attempt.AttemptContext, error, +) { + _, ctx, err := EvaluateRoastRetryForSigning( + a.Coordinator, + a.Handle, + a.Bundle, + a.Threshold, + a.DkgGroupPublicKey, + a.Resolver, + ) + return ctx, err +} diff --git a/pkg/frost/roast/signing_retry_adapter_test.go b/pkg/frost/roast/signing_retry_adapter_test.go new file mode 100644 index 0000000000..6272cc32c0 --- /dev/null +++ b/pkg/frost/roast/signing_retry_adapter_test.go @@ -0,0 +1,251 @@ +package roast + +import ( + "errors" + "fmt" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// addressResolverString is a deterministic resolver that maps +// member index N to the string "addr-N". Used by the adapter +// tests to verify the conversion path without needing chain types. +type addressResolverString struct{} + +func (addressResolverString) For(m group.MemberIndex) (string, error) { + if m == 0 { + return "", fmt.Errorf("zero member index") + } + return fmt.Sprintf("addr-%d", m), nil +} + +// failingResolver always errors. Used to verify that resolver +// failures propagate cleanly through the adapter. +type failingResolver struct{ err error } + +func (f failingResolver) For(_ group.MemberIndex) (string, error) { + return "", f.err +} + +// retryAdapterFixture provides a previously-completed attempt with +// a verified bundle that NextAttempt can transition from. +type retryAdapterFixture struct { + coord Coordinator + handle AttemptHandle + bundle *TransitionMessage + threshold uint + dkgPub []byte +} + +func newRetryAdapterFixture(t *testing.T) *retryAdapterFixture { + t.Helper() + members := []group.MemberIndex{1, 2, 3, 4, 5} + + // Use a throwaway coordinator to discover the elected + // coordinator, then build a real coordinator bound to that + // member as the aggregator. + scratch := NewInMemoryCoordinator() + ctx := mustBuildContext(t, members, nil, nil) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, err := aggregator.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + for _, m := range members { + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{})) + if err := aggregator.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record %d: %v", m, err) + } + } + bundle, err := aggregator.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + return &retryAdapterFixture{ + coord: aggregator, + handle: handle, + bundle: bundle, + threshold: 3, + dkgPub: []byte{0xab, 0xcd, 0xef}, + } +} + +func mustBuildContext( + t *testing.T, + included, excluded, parked []group.MemberIndex, +) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContextWithParking( + "session-test", + "key-group-test", + []byte{0xab, 0xcd, 0xef}, + [attempt.MessageDigestLength]byte{0x42}, + 0, + included, + excluded, + parked, + ) + if err != nil { + t.Fatalf("build ctx: %v", err) + } + return ctx +} + +func TestEvaluateRoastRetryForSigning_HappyPath(t *testing.T) { + f := newRetryAdapterFixture(t) + + addresses, nextCtx, err := EvaluateRoastRetryForSigning[string]( + f.coord, f.handle, f.bundle, f.threshold, f.dkgPub, + addressResolverString{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(addresses) != 5 { + t.Fatalf("expected 5 addresses, got %d", len(addresses)) + } + for i, a := range addresses { + want := fmt.Sprintf("addr-%d", nextCtx.IncludedSet[i]) + if a != want { + t.Fatalf( + "address[%d]: got %q want %q", + i, a, want, + ) + } + } + if nextCtx.AttemptNumber != 1 { + t.Fatalf("attempt number: got %d want 1", nextCtx.AttemptNumber) + } +} + +func TestEvaluateRoastRetryForSigning_PropagatesInfeasibility(t *testing.T) { + f := newRetryAdapterFixture(t) + + _, _, err := EvaluateRoastRetryForSigning[string]( + f.coord, f.handle, f.bundle, 99, f.dkgPub, + addressResolverString{}, + ) + if !errors.Is(err, ErrAttemptInfeasible) { + t.Fatalf("expected ErrAttemptInfeasible, got %v", err) + } +} + +func TestEvaluateRoastRetryForSigning_PropagatesResolverError(t *testing.T) { + f := newRetryAdapterFixture(t) + + sentinel := errors.New("resolver lookup failed") + _, _, err := EvaluateRoastRetryForSigning[string]( + f.coord, f.handle, f.bundle, f.threshold, f.dkgPub, + failingResolver{err: sentinel}, + ) + if err == nil { + t.Fatal("expected resolver error") + } + if !errors.Is(err, sentinel) { + t.Fatalf("expected wrapped sentinel, got %v", err) + } +} + +func TestEvaluateRoastRetryForSigning_RejectsNilCoordinator(t *testing.T) { + _, _, err := EvaluateRoastRetryForSigning[string]( + nil, AttemptHandle{}, &TransitionMessage{}, 3, []byte{0x01}, + addressResolverString{}, + ) + if err == nil { + t.Fatal("expected nil-coordinator error") + } +} + +func TestEvaluateRoastRetryForSigning_RejectsNilResolver(t *testing.T) { + _, _, err := EvaluateRoastRetryForSigning[string]( + NewInMemoryCoordinator(), + AttemptHandle{}, &TransitionMessage{}, 3, []byte{0x01}, + nil, + ) + if err == nil { + t.Fatal("expected nil-resolver error") + } +} + +func TestSigningRetryAdapter_LegacyShapeMatchesPureFunction(t *testing.T) { + f := newRetryAdapterFixture(t) + resolver := addressResolverString{} + + adapter := SigningRetryAdapter[string]{ + Coordinator: f.coord, + Handle: f.handle, + Bundle: f.bundle, + Threshold: f.threshold, + DkgGroupPublicKey: f.dkgPub, + Resolver: resolver, + } + + // Legacy parameters are ignored. + viaAdapter, err := adapter.EvaluateRetryParticipantsForSigning( + nil, 0, 0, 0, + ) + if err != nil { + t.Fatalf("adapter: %v", err) + } + viaFunc, _, err := EvaluateRoastRetryForSigning[string]( + f.coord, f.handle, f.bundle, f.threshold, f.dkgPub, resolver, + ) + if err != nil { + t.Fatalf("function: %v", err) + } + if len(viaAdapter) != len(viaFunc) { + t.Fatalf( + "adapter and function disagree on participant count: %d vs %d", + len(viaAdapter), len(viaFunc), + ) + } + for i := range viaAdapter { + if viaAdapter[i] != viaFunc[i] { + t.Fatalf("adapter[%d] = %q, function[%d] = %q", i, viaAdapter[i], i, viaFunc[i]) + } + } +} + +func TestSigningRetryAdapter_NextAttemptContextRoundTrip(t *testing.T) { + f := newRetryAdapterFixture(t) + adapter := SigningRetryAdapter[string]{ + Coordinator: f.coord, + Handle: f.handle, + Bundle: f.bundle, + Threshold: f.threshold, + DkgGroupPublicKey: f.dkgPub, + Resolver: addressResolverString{}, + } + ctx1, err := adapter.NextAttemptContext() + if err != nil { + t.Fatalf("first: %v", err) + } + ctx2, err := adapter.NextAttemptContext() + if err != nil { + t.Fatalf("second: %v", err) + } + if ctx1.Hash() != ctx2.Hash() { + t.Fatal("NextAttemptContext must be deterministic across calls") + } +} + +func TestSigningRetryAdapter_PropagatesInfeasibility(t *testing.T) { + f := newRetryAdapterFixture(t) + adapter := SigningRetryAdapter[string]{ + Coordinator: f.coord, + Handle: f.handle, + Bundle: f.bundle, + Threshold: 99, + DkgGroupPublicKey: f.dkgPub, + Resolver: addressResolverString{}, + } + _, err := adapter.EvaluateRetryParticipantsForSigning(nil, 0, 0, 0) + if !errors.Is(err, ErrAttemptInfeasible) { + t.Fatalf("expected ErrAttemptInfeasible, got %v", err) + } +} diff --git a/pkg/frost/roast/snapshot_proofs_test.go b/pkg/frost/roast/snapshot_proofs_test.go new file mode 100644 index 0000000000..4fe2318ffb --- /dev/null +++ b/pkg/frost/roast/snapshot_proofs_test.go @@ -0,0 +1,139 @@ +package roast + +import ( + "bytes" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// Proofs are opaque bytes to the snapshot layer: PR2 carries + canonicalizes +// them and folds them into the signed body; PR3 (NextAttempt) verifies them. + +func TestLocalEvidenceSnapshot_CoordinatorPackageProofs_RoundTrip(t *testing.T) { + proofA := []byte("coordinator-signed-package-A") + proofB := []byte("coordinator-signed-package-B") + + // Passed out of order; the constructor must canonicalize (bytewise ascending). + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot( + 3, pinnedContextHash, attempt.Evidence{}, proofB, proofA, + )) + if len(snap.CoordinatorPackageProofs) != 2 || + !bytes.Equal(snap.CoordinatorPackageProofs[0], proofA) || + !bytes.Equal(snap.CoordinatorPackageProofs[1], proofB) { + t.Fatalf("constructor must store proofs in bytewise-ascending order, got %q", + snap.CoordinatorPackageProofs) + } + + wire, err := snap.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(decoded.CoordinatorPackageProofs) != 2 || + !bytes.Equal(decoded.CoordinatorPackageProofs[0], proofA) || + !bytes.Equal(decoded.CoordinatorPackageProofs[1], proofB) { + t.Fatalf("proofs must survive the wire round-trip verbatim, got %q", + decoded.CoordinatorPackageProofs) + } + + // The proofs are part of the signed body: re-marshal returns the received + // bytes verbatim, and the producer/receiver signed payloads match exactly. + rebroadcast, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(rebroadcast, wire) { + t.Fatal("re-marshal of a received snapshot must return the received bytes verbatim") + } + producer, _ := snap.SignableBytes() + receiver, _ := decoded.SignableBytes() + if !bytes.Equal(producer, receiver) { + t.Fatal("the receiver must verify over exactly the bytes the signer signed") + } +} + +func TestLocalEvidenceSnapshot_NoProofsEncodesAsBefore(t *testing.T) { + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot(3, pinnedContextHash, attempt.Evidence{})) + if snap.CoordinatorPackageProofs != nil { + t.Fatalf("no proofs passed -> field must be nil, got %q", snap.CoordinatorPackageProofs) + } + wire, err := snap.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(decoded.CoordinatorPackageProofs) != 0 { + t.Fatalf("a proof-free snapshot must decode with no proofs, got %q", + decoded.CoordinatorPackageProofs) + } +} + +func TestLocalEvidenceSnapshot_CoordinatorPackageProofs_ValidateRejections(t *testing.T) { + base := func() *LocalEvidenceSnapshot { + return &LocalEvidenceSnapshot{ + SenderIDValue: 3, + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + } + } + + t.Run("over the cap", func(t *testing.T) { + s := base() + s.CoordinatorPackageProofs = [][]byte{{0x01}, {0x02}, {0x03}} + if err := s.Validate(); err == nil { + t.Fatal("more than two proofs must be rejected") + } + }) + t.Run("unsorted", func(t *testing.T) { + s := base() + s.CoordinatorPackageProofs = [][]byte{{0x02}, {0x01}} + if err := s.Validate(); err == nil { + t.Fatal("unsorted proofs must be rejected") + } + }) + t.Run("duplicate", func(t *testing.T) { + s := base() + s.CoordinatorPackageProofs = [][]byte{{0x01}, {0x01}} + if err := s.Validate(); err == nil { + t.Fatal("duplicate proofs must be rejected") + } + }) + t.Run("empty proof", func(t *testing.T) { + s := base() + s.CoordinatorPackageProofs = [][]byte{{}} + if err := s.Validate(); err == nil { + t.Fatal("an empty proof must be rejected") + } + }) + t.Run("oversized proof", func(t *testing.T) { + s := base() + s.CoordinatorPackageProofs = [][]byte{bytes.Repeat([]byte{0x01}, MaxSignedSigningPackageBytes+1)} + if err := s.Validate(); err == nil { + t.Fatal("an oversized proof must be rejected") + } + }) + t.Run("valid two", func(t *testing.T) { + s := base() + s.CoordinatorPackageProofs = [][]byte{{0x01}, {0x02}} + if err := s.Validate(); err != nil { + t.Fatalf("two sorted, distinct, bounded proofs must validate: %v", err) + } + }) +} + +func TestLocalEvidenceSnapshot_Unmarshal_RejectsOversizedEnvelope(t *testing.T) { + // Carrying coordinator package proofs makes a legitimate snapshot MBs large, + // so Unmarshal must reject a grossly oversized envelope before the protobuf + // decoder allocates for it (memory-DoS guard, mirroring SigningPackage / + // ShareSubmission). TransitionMessage.Unmarshal applies the analogous cap. + oversized := make([]byte, MaxSignedLocalEvidenceSnapshotBytes+1) + if err := (&LocalEvidenceSnapshot{}).Unmarshal(oversized); err == nil { + t.Fatal("an oversized snapshot envelope must be rejected before decoding") + } +} diff --git a/pkg/frost/roast/testdata/coordinator_seed_vectors.json b/pkg/frost/roast/testdata/coordinator_seed_vectors.json new file mode 100644 index 0000000000..6e9a722083 --- /dev/null +++ b/pkg/frost/roast/testdata/coordinator_seed_vectors.json @@ -0,0 +1,459 @@ +{ + "description": "Cross-language conformance vectors for the RFC-21 Annex A coordinator-shuffle seed derivation: ShuffleSeed_i64 = int64_be(SHA256(KeyGroupBytes || SessionID || MessageDigest)[0:8]); coordinator = GoMathRandShuffle(sorted(IncludedMembers), ShuffleSeed_i64 + int64(AttemptNumber))[0] with the 0-based RFC-21 AttemptNumber. wireAttemptNumber is the 1-based tbtc-signer FFI encoding of the same attempt. Canonical copy: pkg/frost/roast/testdata/coordinator_seed_vectors.json (Go); mirrored byte-identically to pkg/tbtc/signer/testdata/coordinator_seed_vectors.json (Rust).", + "vectors": [ + { + "name": "five-members-attempt-0", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 3 + }, + { + "name": "five-members-attempt-1", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 1, + "wireAttemptNumber": 2, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 4 + }, + { + "name": "five-members-attempt-5", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 5, + "wireAttemptNumber": 6, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 5 + }, + { + "name": "sparse-members-attempt-0", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 2, + 7, + 9, + 11 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 11 + }, + { + "name": "different-session-changes-seed", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-2", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-4761519992160790326", + "expectedCoordinator": 4 + }, + { + "name": "different-digest-changes-seed", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "4077301444353698382", + "expectedCoordinator": 1 + }, + { + "name": "opaque-key-group-handle", + "keyGroup": "roast-vector-opaque-key-group-handle", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "8068678687664591308", + "expectedCoordinator": 5 + }, + { + "name": "production-group-size-attempt-0", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 55 + }, + { + "name": "production-group-size-attempt-3", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attemptNumber": 3, + "wireAttemptNumber": 4, + "expectedShuffleSeedInt64": "4077301444353698382", + "expectedCoordinator": 15 + }, + { + "name": "opaque-key-group-wide-set-attempt-7", + "keyGroup": "roast-vector-opaque-key-group-handle", + "sessionID": "session-roast-seed-vector-2", + "messageDigestHex": "f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attemptNumber": 7, + "wireAttemptNumber": 8, + "expectedShuffleSeedInt64": "-6872856820098921194", + "expectedCoordinator": 18 + } + ] +} diff --git a/pkg/frost/roast/testdata/coordinator_shuffle_corpus.json b/pkg/frost/roast/testdata/coordinator_shuffle_corpus.json new file mode 100644 index 0000000000..0672967760 --- /dev/null +++ b/pkg/frost/roast/testdata/coordinator_shuffle_corpus.json @@ -0,0 +1 @@ +{"description":"Cross-language differential corpus for the legacy Go math/rand coordinator shuffle (SelectCoordinator / go_math_rand.rs select_coordinator_identifier): source seed = seedInt64 + int64(attemptNumber) with two's-complement wrapping; members sorted ascending internally before the Fisher-Yates shuffle; first element after shuffling is the coordinator. Canonical copy: pkg/frost/roast/testdata/coordinator_shuffle_corpus.json (Go); mirrored byte-identically to pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json (Rust). Regenerate with ROAST_SHUFFLE_CORPUS_REGEN=1.","cases":[{"name":"boundary-seed-0-attempt-0-set-0","seedInt64":"0","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-0-set-1","seedInt64":"0","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-0-set-2","seedInt64":"0","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-0-set-3","seedInt64":"0","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-0-set-4","seedInt64":"0","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-0-set-5","seedInt64":"0","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed-0-attempt-1-set-0","seedInt64":"0","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-1-set-1","seedInt64":"0","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-1-set-2","seedInt64":"0","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-1-set-3","seedInt64":"0","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-1-set-4","seedInt64":"0","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-1-set-5","seedInt64":"0","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-0-attempt-7-set-0","seedInt64":"0","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-7-set-1","seedInt64":"0","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-7-set-2","seedInt64":"0","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-7-set-3","seedInt64":"0","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-7-set-4","seedInt64":"0","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-7-set-5","seedInt64":"0","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-0-attempt-4294967295-set-0","seedInt64":"0","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-4294967295-set-1","seedInt64":"0","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-4294967295-set-2","seedInt64":"0","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-4294967295-set-3","seedInt64":"0","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-4294967295-set-4","seedInt64":"0","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-4294967295-set-5","seedInt64":"0","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-0-set-0","seedInt64":"1","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-0-set-1","seedInt64":"1","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-0-set-2","seedInt64":"1","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-0-set-3","seedInt64":"1","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-1-attempt-0-set-4","seedInt64":"1","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-1-attempt-0-set-5","seedInt64":"1","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-1-set-0","seedInt64":"1","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-1-set-1","seedInt64":"1","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-1-set-2","seedInt64":"1","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-1-set-3","seedInt64":"1","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-1-attempt-1-set-4","seedInt64":"1","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-1-attempt-1-set-5","seedInt64":"1","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-1-attempt-7-set-0","seedInt64":"1","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-7-set-1","seedInt64":"1","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-7-set-2","seedInt64":"1","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-7-set-3","seedInt64":"1","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-7-set-4","seedInt64":"1","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-7-set-5","seedInt64":"1","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-4294967295-set-0","seedInt64":"1","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-4294967295-set-1","seedInt64":"1","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-4294967295-set-2","seedInt64":"1","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-4294967295-set-3","seedInt64":"1","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-1-attempt-4294967295-set-4","seedInt64":"1","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-1-attempt-4294967295-set-5","seedInt64":"1","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed--1-attempt-0-set-0","seedInt64":"-1","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-0-set-1","seedInt64":"-1","attemptNumber":0,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-0-set-2","seedInt64":"-1","attemptNumber":0,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-0-set-3","seedInt64":"-1","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--1-attempt-0-set-4","seedInt64":"-1","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--1-attempt-0-set-5","seedInt64":"-1","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--1-attempt-1-set-0","seedInt64":"-1","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-1-set-1","seedInt64":"-1","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-1-set-2","seedInt64":"-1","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-1-set-3","seedInt64":"-1","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-1-set-4","seedInt64":"-1","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-1-set-5","seedInt64":"-1","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed--1-attempt-7-set-0","seedInt64":"-1","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-7-set-1","seedInt64":"-1","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-7-set-2","seedInt64":"-1","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-7-set-3","seedInt64":"-1","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-7-set-4","seedInt64":"-1","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-7-set-5","seedInt64":"-1","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-4294967295-set-0","seedInt64":"-1","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-4294967295-set-1","seedInt64":"-1","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-4294967295-set-2","seedInt64":"-1","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-4294967295-set-3","seedInt64":"-1","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-4294967295-set-4","seedInt64":"-1","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-4294967295-set-5","seedInt64":"-1","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed-9223372036854775807-attempt-0-set-0","seedInt64":"9223372036854775807","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-0-set-1","seedInt64":"9223372036854775807","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-0-set-2","seedInt64":"9223372036854775807","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-0-set-3","seedInt64":"9223372036854775807","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-9223372036854775807-attempt-0-set-4","seedInt64":"9223372036854775807","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-9223372036854775807-attempt-0-set-5","seedInt64":"9223372036854775807","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775807-attempt-1-set-0","seedInt64":"9223372036854775807","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-1","seedInt64":"9223372036854775807","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-2","seedInt64":"9223372036854775807","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-3","seedInt64":"9223372036854775807","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-4","seedInt64":"9223372036854775807","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-5","seedInt64":"9223372036854775807","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775807-attempt-7-set-0","seedInt64":"9223372036854775807","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-7-set-1","seedInt64":"9223372036854775807","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775807-attempt-7-set-2","seedInt64":"9223372036854775807","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775807-attempt-7-set-3","seedInt64":"9223372036854775807","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775807-attempt-7-set-4","seedInt64":"9223372036854775807","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775807-attempt-7-set-5","seedInt64":"9223372036854775807","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-0","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-1","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-2","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-3","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-4","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-5","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-0-set-0","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-1","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-2","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-3","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-4","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-5","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-1-set-0","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-1-set-1","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-1-set-2","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-1-set-3","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775808-attempt-1-set-4","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775808-attempt-1-set-5","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--9223372036854775808-attempt-7-set-0","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-1","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-2","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-3","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-4","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-5","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-0","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-1","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-2","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-3","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-4","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-5","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed-9223372036854775804-attempt-0-set-0","seedInt64":"9223372036854775804","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-1","seedInt64":"9223372036854775804","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-2","seedInt64":"9223372036854775804","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-3","seedInt64":"9223372036854775804","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-4","seedInt64":"9223372036854775804","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-5","seedInt64":"9223372036854775804","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-1-set-0","seedInt64":"9223372036854775804","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-1-set-1","seedInt64":"9223372036854775804","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-1-set-2","seedInt64":"9223372036854775804","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-1-set-3","seedInt64":"9223372036854775804","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775804-attempt-1-set-4","seedInt64":"9223372036854775804","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775804-attempt-1-set-5","seedInt64":"9223372036854775804","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed-9223372036854775804-attempt-7-set-0","seedInt64":"9223372036854775804","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-7-set-1","seedInt64":"9223372036854775804","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-7-set-2","seedInt64":"9223372036854775804","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-7-set-3","seedInt64":"9223372036854775804","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-9223372036854775804-attempt-7-set-4","seedInt64":"9223372036854775804","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-9223372036854775804-attempt-7-set-5","seedInt64":"9223372036854775804","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-0","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-1","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-2","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-3","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-4","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-5","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed--9223372036854775805-attempt-0-set-0","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-0-set-1","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-0-set-2","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-0-set-3","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--9223372036854775805-attempt-0-set-4","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--9223372036854775805-attempt-0-set-5","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-1-set-0","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-1-set-1","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-1-set-2","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-1-set-3","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775805-attempt-1-set-4","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775805-attempt-1-set-5","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed--9223372036854775805-attempt-7-set-0","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-7-set-1","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-7-set-2","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-7-set-3","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-7-set-4","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-7-set-5","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-0","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-1","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-2","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-3","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-4","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-5","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-2147483647-attempt-0-set-0","seedInt64":"2147483647","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-0-set-1","seedInt64":"2147483647","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-0-set-2","seedInt64":"2147483647","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-0-set-3","seedInt64":"2147483647","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-0-set-4","seedInt64":"2147483647","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-0-set-5","seedInt64":"2147483647","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed-2147483647-attempt-1-set-0","seedInt64":"2147483647","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-1-set-1","seedInt64":"2147483647","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-1-set-2","seedInt64":"2147483647","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-1-set-3","seedInt64":"2147483647","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-1-set-4","seedInt64":"2147483647","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-1-set-5","seedInt64":"2147483647","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-2147483647-attempt-7-set-0","seedInt64":"2147483647","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-7-set-1","seedInt64":"2147483647","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-7-set-2","seedInt64":"2147483647","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-7-set-3","seedInt64":"2147483647","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-7-set-4","seedInt64":"2147483647","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-7-set-5","seedInt64":"2147483647","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-2147483647-attempt-4294967295-set-0","seedInt64":"2147483647","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-4294967295-set-1","seedInt64":"2147483647","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-4294967295-set-2","seedInt64":"2147483647","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-4294967295-set-3","seedInt64":"2147483647","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-4294967295-set-4","seedInt64":"2147483647","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-4294967295-set-5","seedInt64":"2147483647","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--2147483647-attempt-0-set-0","seedInt64":"-2147483647","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-0-set-1","seedInt64":"-2147483647","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-0-set-2","seedInt64":"-2147483647","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-0-set-3","seedInt64":"-2147483647","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-0-set-4","seedInt64":"-2147483647","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-0-set-5","seedInt64":"-2147483647","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed--2147483647-attempt-1-set-0","seedInt64":"-2147483647","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-1-set-1","seedInt64":"-2147483647","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-1-set-2","seedInt64":"-2147483647","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-1-set-3","seedInt64":"-2147483647","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-1-set-4","seedInt64":"-2147483647","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-1-set-5","seedInt64":"-2147483647","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--2147483647-attempt-7-set-0","seedInt64":"-2147483647","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-7-set-1","seedInt64":"-2147483647","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-7-set-2","seedInt64":"-2147483647","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-7-set-3","seedInt64":"-2147483647","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-7-set-4","seedInt64":"-2147483647","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-7-set-5","seedInt64":"-2147483647","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed--2147483647-attempt-4294967295-set-0","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-4294967295-set-1","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-4294967295-set-2","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-4294967295-set-3","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-4294967295-set-4","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-4294967295-set-5","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-0-set-0","seedInt64":"6879463052285329321","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-0-set-1","seedInt64":"6879463052285329321","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-0-set-2","seedInt64":"6879463052285329321","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-0-set-3","seedInt64":"6879463052285329321","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-0-set-4","seedInt64":"6879463052285329321","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-0-set-5","seedInt64":"6879463052285329321","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed-6879463052285329321-attempt-1-set-0","seedInt64":"6879463052285329321","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-1-set-1","seedInt64":"6879463052285329321","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-1-set-2","seedInt64":"6879463052285329321","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-1-set-3","seedInt64":"6879463052285329321","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-1-set-4","seedInt64":"6879463052285329321","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-1-set-5","seedInt64":"6879463052285329321","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed-6879463052285329321-attempt-7-set-0","seedInt64":"6879463052285329321","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-7-set-1","seedInt64":"6879463052285329321","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-7-set-2","seedInt64":"6879463052285329321","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-7-set-3","seedInt64":"6879463052285329321","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-7-set-4","seedInt64":"6879463052285329321","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-7-set-5","seedInt64":"6879463052285329321","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-0","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-1","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-2","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-3","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-4","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-5","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--6879463052285329321-attempt-0-set-0","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-0-set-1","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-0-set-2","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-0-set-3","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":5},{"name":"boundary-seed--6879463052285329321-attempt-0-set-4","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":5},{"name":"boundary-seed--6879463052285329321-attempt-0-set-5","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--6879463052285329321-attempt-1-set-0","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-1-set-1","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-1-set-2","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-1-set-3","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-1-set-4","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-1-set-5","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed--6879463052285329321-attempt-7-set-0","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-7-set-1","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-7-set-2","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-7-set-3","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-7-set-4","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-7-set-5","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-0","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-1","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-2","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-3","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-4","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-5","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"generated-000-size-1","seedInt64":"6295792554059532962","attemptNumber":5,"members":[41],"expectedCoordinator":41},{"name":"generated-001-size-46","seedInt64":"3683221734673789976","attemptNumber":25023,"members":[245,146,229,247,218,221,74,189,27,209,214,200,183,77,114,184,203,34,141,161,253,21,123,47,243,111,228,68,196,244,93,33,24,80,222,82,102,152,66,4,232,190,198,89,15,10],"expectedCoordinator":247},{"name":"generated-002-size-72","seedInt64":"5813114611858962076","attemptNumber":2051719421,"members":[162,201,231,8,101,229,245,148,124,187,222,255,238,27,26,4,157,145,35,134,55,105,129,150,90,37,214,218,189,3,60,45,217,247,63,115,250,248,120,192,138,190,130,132,51,96,34,74,24,122,169,137,69,85,242,62,78,54,178,243,103,184,23,128,241,237,86,252,160,89,15,234],"expectedCoordinator":189},{"name":"generated-003-size-126","seedInt64":"-5707769007413102449","attemptNumber":2,"members":[152,127,2,14,96,4,199,46,55,57,116,79,222,196,148,228,169,215,207,39,202,61,223,33,59,38,171,135,43,163,71,134,122,110,35,76,112,125,120,84,64,40,208,238,195,81,191,88,124,31,94,183,214,137,180,197,254,140,78,237,205,170,138,15,19,145,153,159,231,219,85,74,16,200,119,18,216,128,181,227,86,155,32,192,89,206,47,165,106,253,24,198,184,13,162,7,69,17,95,56,23,132,166,136,60,210,182,118,72,221,156,98,229,62,104,49,115,97,34,41,26,65,22,204,111,179],"expectedCoordinator":153},{"name":"generated-004-size-4","seedInt64":"-4719280119571122224","attemptNumber":61267,"members":[78,72,53,100],"expectedCoordinator":78},{"name":"generated-005-size-43","seedInt64":"8691200825180697649","attemptNumber":2383742276,"members":[191,161,254,211,152,248,123,137,143,17,40,223,196,185,23,158,105,198,76,62,124,183,37,114,31,236,176,89,237,98,22,15,7,11,70,118,233,242,44,48,127,132,66],"expectedCoordinator":237},{"name":"generated-006-size-73","seedInt64":"6233619617998794694","attemptNumber":2,"members":[27,171,221,158,57,198,124,36,68,161,163,53,217,212,26,83,135,235,21,155,137,128,172,167,141,43,76,145,102,214,211,183,160,178,175,199,54,16,72,18,143,206,193,32,231,127,185,114,41,14,55,203,104,180,12,92,8,120,177,117,119,222,208,113,204,213,191,218,246,31,166,187,253],"expectedCoordinator":185},{"name":"generated-007-size-123","seedInt64":"5434904758717572839","attemptNumber":65179,"members":[98,188,13,15,231,17,77,26,180,187,12,248,218,119,70,215,147,93,198,179,195,67,41,164,214,6,206,84,94,47,5,110,192,169,217,44,48,128,60,3,141,138,116,58,151,88,81,72,105,246,205,172,189,9,144,37,34,42,154,165,20,253,167,161,45,143,66,125,96,229,50,115,78,153,250,85,54,82,53,97,182,46,14,131,55,74,1,33,107,221,251,136,184,118,243,51,171,36,127,75,19,135,87,150,235,249,49,31,57,139,226,76,22,43,236,156,244,183,219,101,30,18,83],"expectedCoordinator":6},{"name":"generated-008-size-5","seedInt64":"6796119988171906023","attemptNumber":3146658771,"members":[171,70,74,130,99],"expectedCoordinator":74},{"name":"generated-009-size-6","seedInt64":"-6337975409404583314","attemptNumber":3,"members":[56,130,93,250,149,19],"expectedCoordinator":149},{"name":"generated-010-size-89","seedInt64":"-771078845577344560","attemptNumber":31710,"members":[221,39,206,244,108,88,103,118,75,4,114,168,28,7,32,178,62,255,100,238,153,146,135,188,30,154,139,97,184,254,174,145,216,235,10,129,5,121,209,172,152,25,6,21,149,230,29,246,148,99,49,164,156,214,41,9,15,61,210,72,242,27,24,98,201,126,57,127,181,96,225,186,19,200,177,217,70,249,176,130,69,17,131,226,48,52,46,197,236],"expectedCoordinator":9},{"name":"generated-011-size-165","seedInt64":"-1612711212753147549","attemptNumber":3039875824,"members":[235,78,215,108,100,88,19,43,104,76,59,231,141,21,157,96,27,204,3,134,57,24,64,173,253,125,45,14,40,26,191,111,246,121,9,239,193,183,155,84,176,48,63,54,162,52,47,252,110,33,166,115,194,120,202,127,148,58,62,168,116,140,216,65,153,6,220,80,124,133,99,236,101,177,74,196,77,159,17,156,117,41,11,102,163,199,86,91,20,12,187,81,233,158,218,79,213,238,10,56,69,73,49,130,42,97,192,161,128,25,72,16,234,214,219,13,186,83,1,223,5,126,182,114,87,170,228,212,229,85,152,29,197,180,248,144,243,23,35,174,227,175,71,203,98,50,167,44,122,94,53,103,210,112,60,18,209,4,230,205,31,132,245,222,75],"expectedCoordinator":23},{"name":"generated-012-size-2","seedInt64":"-6750454712375239825","attemptNumber":7,"members":[29,55],"expectedCoordinator":29},{"name":"generated-013-size-32","seedInt64":"-2220607361023621506","attemptNumber":25246,"members":[125,200,178,122,117,167,15,127,67,228,237,163,233,191,72,138,188,31,217,141,54,204,41,118,225,119,152,40,9,100,185,129],"expectedCoordinator":127},{"name":"generated-014-size-96","seedInt64":"5840308848227394044","attemptNumber":1144450877,"members":[142,106,146,172,235,61,93,239,122,20,17,22,2,151,97,255,224,222,188,205,29,187,11,126,39,170,173,94,65,33,91,60,207,241,234,32,157,63,70,150,76,78,183,195,245,140,104,143,247,179,123,26,82,41,152,124,116,119,148,62,18,192,68,87,252,28,176,127,154,171,54,240,105,49,67,112,66,166,153,232,233,138,236,55,100,57,47,46,168,15,21,6,177,137,130,189],"expectedCoordinator":143},{"name":"generated-015-size-175","seedInt64":"-4937924503753896750","attemptNumber":1,"members":[243,172,77,137,3,97,132,48,61,235,213,38,149,70,18,93,144,109,67,4,147,107,187,84,125,155,46,222,140,211,19,26,36,151,66,88,40,139,28,173,112,62,113,45,22,244,119,73,51,35,17,108,250,176,91,63,195,43,221,154,57,183,135,27,162,71,182,53,231,216,12,21,143,236,181,209,141,126,42,129,72,7,83,136,215,50,90,153,175,210,207,118,82,239,39,185,8,37,106,124,114,223,58,168,248,80,79,227,230,165,190,25,85,212,150,157,78,164,159,68,201,133,160,229,254,199,198,2,189,59,123,47,110,174,233,251,111,60,69,75,237,101,65,226,6,202,177,186,86,24,204,219,81,127,96,100,9,89,208,76,117,179,33,252,255,224,214,238,206,29,14,103,130,92,241],"expectedCoordinator":7},{"name":"generated-016-size-4","seedInt64":"-4123447390068133991","attemptNumber":29338,"members":[244,170,225,171],"expectedCoordinator":244},{"name":"generated-017-size-50","seedInt64":"1172508388763733295","attemptNumber":1209681019,"members":[122,117,142,158,98,7,163,118,230,242,137,21,209,5,40,108,103,162,237,153,13,18,215,70,221,185,60,131,59,107,197,114,245,78,124,236,154,112,77,52,176,46,88,217,172,218,19,212,31,228],"expectedCoordinator":98},{"name":"generated-018-size-84","seedInt64":"318580645635970852","attemptNumber":5,"members":[131,142,183,197,76,11,68,209,52,23,136,21,215,139,120,91,226,177,173,164,98,140,53,194,72,169,47,192,100,75,163,217,240,141,144,223,17,36,14,151,178,181,172,255,6,108,251,154,234,225,38,67,143,58,44,242,237,210,104,166,61,39,200,96,213,10,195,148,254,113,5,45,204,252,233,174,250,27,51,247,31,248,15,64],"expectedCoordinator":183},{"name":"generated-019-size-123","seedInt64":"6410882182387587974","attemptNumber":53440,"members":[232,203,212,108,67,91,237,154,111,127,99,244,147,27,81,133,101,14,145,100,215,158,75,68,213,221,24,249,226,207,159,58,211,36,57,174,77,119,162,39,20,54,218,242,63,248,234,86,6,250,46,61,121,255,172,130,38,104,71,34,52,93,107,33,105,43,16,135,82,126,202,73,141,156,153,223,241,243,102,49,109,66,8,125,113,178,148,115,173,239,114,151,78,26,139,245,30,74,92,210,247,230,222,80,165,3,225,195,198,238,217,10,183,94,96,175,252,44,227,84,116,85,166],"expectedCoordinator":27},{"name":"generated-020-size-1","seedInt64":"5763849915000817686","attemptNumber":3899514617,"members":[226],"expectedCoordinator":226},{"name":"generated-021-size-15","seedInt64":"-5876902395680547245","attemptNumber":7,"members":[131,21,211,177,132,63,83,252,82,104,41,122,231,191,221],"expectedCoordinator":83},{"name":"generated-022-size-53","seedInt64":"-4289660314772571792","attemptNumber":58396,"members":[194,206,243,99,130,78,47,165,220,76,42,246,140,218,224,17,124,201,36,192,64,104,233,212,152,49,134,109,145,254,160,88,67,18,20,26,203,235,28,227,98,170,138,87,169,133,154,77,56,150,118,239,62],"expectedCoordinator":212},{"name":"generated-023-size-168","seedInt64":"-8553401789794906947","attemptNumber":608250899,"members":[222,82,51,173,81,245,47,50,228,26,209,163,32,83,114,172,77,16,15,98,140,160,149,49,234,91,201,88,252,185,136,206,23,237,38,12,72,142,184,156,69,57,33,96,63,103,248,90,29,122,186,97,85,211,111,174,46,95,28,183,219,249,6,223,255,11,220,137,166,199,7,132,214,56,177,124,62,130,117,235,203,109,167,18,4,192,100,162,105,197,229,102,236,120,251,182,65,93,74,190,5,20,31,121,43,227,94,230,158,238,150,193,106,242,14,116,66,161,152,196,178,60,135,2,64,101,188,133,108,115,218,92,170,143,112,134,52,68,53,75,86,54,168,41,35,169,225,126,113,153,104,233,36,48,250,17,27,39,123,200,204,59,3,84,145,212,127,246],"expectedCoordinator":245},{"name":"generated-024-size-2","seedInt64":"-5658622308577573375","attemptNumber":2,"members":[200,220],"expectedCoordinator":200},{"name":"generated-025-size-30","seedInt64":"-1797060320633428062","attemptNumber":30275,"members":[171,89,34,152,230,40,125,134,140,32,224,196,188,150,240,92,197,249,17,199,228,155,243,48,1,176,25,235,255,72],"expectedCoordinator":176},{"name":"generated-026-size-84","seedInt64":"5250808479438135811","attemptNumber":2549501700,"members":[60,40,134,39,74,140,71,135,105,239,130,203,22,8,219,160,232,187,227,154,75,66,241,229,4,12,127,150,182,42,1,139,44,226,90,183,207,96,121,91,224,103,118,13,170,136,163,193,123,56,53,98,20,120,47,161,158,234,3,167,54,113,243,145,250,100,225,240,116,188,124,14,237,106,104,77,48,65,247,180,67,133,11,115],"expectedCoordinator":130},{"name":"generated-027-size-126","seedInt64":"-5969063946257651826","attemptNumber":0,"members":[245,204,228,250,182,193,236,55,30,4,92,173,205,103,1,199,66,168,72,80,155,18,118,143,76,192,127,123,165,67,138,8,145,86,161,222,242,70,11,115,56,142,137,15,233,249,71,21,2,7,210,237,50,116,94,130,109,33,227,169,175,181,246,60,97,121,120,140,185,134,83,57,223,107,240,110,78,35,12,200,114,141,112,215,111,75,232,59,150,64,22,133,154,124,149,41,69,63,178,186,183,174,16,99,231,125,14,162,148,179,189,214,139,91,53,20,251,219,84,255,68,48,147,197,184,119],"expectedCoordinator":68},{"name":"generated-028-size-4","seedInt64":"-6448027447294810852","attemptNumber":16135,"members":[123,111,99,46],"expectedCoordinator":123},{"name":"generated-029-size-12","seedInt64":"-9187419539375737694","attemptNumber":26833155,"members":[161,200,181,155,55,118,172,159,110,134,233,87],"expectedCoordinator":233},{"name":"generated-030-size-54","seedInt64":"8330553618221158036","attemptNumber":0,"members":[41,221,231,159,254,40,93,168,200,157,150,68,243,215,140,158,42,117,172,137,213,126,85,226,14,7,37,183,116,246,237,52,222,17,242,10,239,24,121,191,46,207,113,13,62,165,166,219,209,178,188,205,39,92],"expectedCoordinator":205},{"name":"generated-031-size-243","seedInt64":"8971795945033806564","attemptNumber":4203,"members":[106,16,177,18,193,189,227,41,5,164,64,4,241,49,52,194,60,31,117,142,233,221,187,219,159,151,115,110,202,12,74,68,73,136,108,3,181,58,168,127,155,161,61,126,70,180,226,56,103,29,89,222,203,100,67,47,46,27,40,250,130,234,251,71,57,90,165,143,231,76,26,55,20,179,229,72,131,149,254,101,82,78,132,98,62,218,178,105,238,25,1,154,141,119,114,14,242,152,209,183,63,211,174,48,128,207,145,45,237,236,182,198,21,225,170,87,30,28,129,36,210,124,107,195,99,93,125,13,11,111,113,252,247,220,133,65,95,24,208,160,205,188,54,217,22,102,191,167,135,169,253,171,212,84,81,158,186,150,35,134,94,85,172,79,147,53,34,156,109,204,196,228,39,9,148,86,112,43,224,157,118,216,122,15,91,139,197,239,215,10,213,37,235,19,92,175,66,17,244,138,50,243,6,163,245,146,33,104,153,246,80,116,248,192,173,176,240,140,42,75,162,32,96,83,2,166,44,51,255,137,77,23,185,200,97,120,199,38,223,69,230,88,59],"expectedCoordinator":247},{"name":"generated-032-size-4","seedInt64":"-833771324673364962","attemptNumber":2036923630,"members":[60,128,110,216],"expectedCoordinator":110},{"name":"generated-033-size-16","seedInt64":"-5267224248859110678","attemptNumber":2,"members":[37,6,55,255,183,118,1,195,78,210,164,240,156,35,70,253],"expectedCoordinator":1},{"name":"generated-034-size-71","seedInt64":"-1680130775691242466","attemptNumber":60241,"members":[101,94,162,109,167,104,126,103,92,138,137,56,208,148,61,84,125,180,246,145,60,201,147,195,98,252,229,113,211,207,44,244,6,10,99,160,36,33,71,133,135,203,25,88,255,58,210,219,173,3,200,146,185,90,27,163,190,128,12,230,159,206,40,151,91,89,194,51,46,102,23],"expectedCoordinator":252},{"name":"generated-035-size-212","seedInt64":"-6235766049191007581","attemptNumber":299683510,"members":[26,176,204,98,10,100,54,55,173,72,162,196,146,93,18,234,1,88,21,42,114,206,96,83,246,49,24,37,27,80,64,78,31,137,164,242,235,14,180,69,39,48,221,103,115,6,111,185,8,243,127,63,190,136,165,125,145,67,25,60,77,52,247,191,2,94,132,175,177,15,222,117,84,195,155,61,167,122,156,3,154,205,90,74,142,58,7,159,23,158,250,101,248,231,119,211,189,28,239,66,129,65,220,238,200,47,174,16,134,244,163,76,89,226,253,85,133,151,75,141,135,126,150,160,120,147,138,44,184,86,36,34,46,215,107,139,223,92,109,110,41,170,33,149,118,123,40,57,161,245,108,241,59,38,166,71,157,95,251,192,186,32,144,227,219,124,70,152,73,45,188,194,218,128,30,169,29,199,207,113,22,201,82,178,193,102,68,81,104,168,79,143,62,121,19,240,43,116,197,216,252,225,202,11,237,106,255,53,9,50,214,254],"expectedCoordinator":121},{"name":"generated-036-size-1","seedInt64":"6087355440257665262","attemptNumber":0,"members":[188],"expectedCoordinator":188},{"name":"generated-037-size-47","seedInt64":"-6783650387694307171","attemptNumber":61642,"members":[25,229,240,26,235,249,149,106,101,31,237,80,163,23,60,89,108,220,85,70,158,109,78,128,37,182,51,181,153,253,196,225,234,140,160,117,118,40,103,161,68,179,157,192,2,207,16],"expectedCoordinator":70},{"name":"generated-038-size-60","seedInt64":"4098794603937378604","attemptNumber":4204848657,"members":[152,220,171,234,34,190,23,123,5,125,53,50,42,54,37,43,176,213,92,186,164,86,87,226,183,97,13,91,7,163,24,60,74,61,82,47,144,3,151,98,4,88,205,146,90,36,71,27,212,126,66,52,128,206,218,103,68,127,44,75],"expectedCoordinator":52},{"name":"generated-039-size-254","seedInt64":"-4758197881576074366","attemptNumber":5,"members":[49,59,78,75,162,77,246,186,227,250,140,167,228,201,177,90,99,55,159,243,10,176,252,164,158,247,223,170,135,94,171,80,193,235,141,168,203,249,115,36,62,38,155,106,183,12,93,185,224,233,148,239,127,163,197,66,147,81,23,238,132,234,9,192,25,18,149,31,22,184,20,42,215,153,173,120,130,180,210,58,124,53,79,73,101,138,134,83,40,166,56,112,178,91,214,199,15,52,229,16,204,182,248,236,103,181,6,108,3,19,85,198,194,139,100,150,137,21,196,107,133,70,245,76,231,218,209,47,41,65,123,8,222,237,208,240,74,129,126,1,213,154,118,102,39,27,136,113,142,219,11,165,207,212,121,217,48,57,82,96,28,161,50,92,61,205,195,110,67,33,128,89,32,87,14,45,169,88,190,145,255,191,226,29,160,146,220,17,24,7,13,37,244,189,241,30,187,230,54,225,43,174,26,68,179,254,72,202,119,44,97,117,122,105,206,216,98,172,5,2,125,95,46,131,232,104,188,116,84,35,86,51,69,111,63,114,34,211,156,152,144,151,157,221,64,253,143,60,109,242,175,251,200,4],"expectedCoordinator":36},{"name":"generated-040-size-4","seedInt64":"-1271822745184727659","attemptNumber":32493,"members":[232,141,115,166],"expectedCoordinator":166},{"name":"generated-041-size-25","seedInt64":"8287361681381761758","attemptNumber":1987866319,"members":[242,169,166,13,255,154,44,155,197,84,212,244,235,59,170,131,142,185,138,191,98,85,64,156,19],"expectedCoordinator":166},{"name":"generated-042-size-60","seedInt64":"2776996516729268456","attemptNumber":1,"members":[121,182,41,159,99,209,246,188,55,214,82,189,13,15,7,107,215,94,128,163,237,104,18,250,49,32,124,218,223,211,175,12,28,187,110,116,85,38,177,135,141,10,102,239,84,5,253,252,25,147,103,216,158,169,35,166,149,90,108,241],"expectedCoordinator":25},{"name":"generated-043-size-236","seedInt64":"-3363036736625666256","attemptNumber":28183,"members":[39,36,207,141,247,112,102,228,105,115,64,18,165,146,82,26,227,34,139,28,126,177,8,35,4,27,255,145,248,77,130,32,187,81,121,52,246,133,245,123,191,149,235,41,230,154,137,226,132,201,14,109,162,244,31,3,193,100,232,170,58,171,45,202,135,175,124,208,94,166,189,90,197,37,78,53,215,234,103,1,239,106,151,57,15,184,110,190,203,71,33,74,89,161,72,6,250,125,80,168,55,66,92,117,224,252,30,195,93,222,214,251,210,181,84,174,43,76,211,20,167,147,192,240,221,218,153,131,25,229,61,65,233,241,60,163,182,173,86,40,136,238,13,176,220,73,91,127,180,122,50,148,128,186,47,87,172,5,48,155,68,114,44,56,62,143,51,54,216,200,183,219,113,67,101,120,169,10,199,138,188,205,88,157,223,70,63,178,209,21,96,85,194,23,144,118,16,142,69,79,104,204,11,150,243,49,198,38,231,46,22,19,42,116,107,160,75,111,185,237,9,253,95,179,24,225,29,242,108,119,254,212,140,152,98,249],"expectedCoordinator":219},{"name":"generated-044-size-1","seedInt64":"-6879629767712052636","attemptNumber":344504451,"members":[184],"expectedCoordinator":184},{"name":"generated-045-size-51","seedInt64":"7961474955424747536","attemptNumber":1,"members":[21,232,244,79,107,127,54,111,100,181,148,67,240,160,195,33,99,162,179,117,200,235,118,36,154,169,60,88,205,208,191,243,13,134,115,136,199,40,159,146,77,212,178,58,113,217,138,142,173,163,114],"expectedCoordinator":195},{"name":"generated-046-size-55","seedInt64":"-8318809590425626998","attemptNumber":16696,"members":[62,18,155,187,106,79,11,176,33,65,44,59,21,3,99,45,205,112,180,251,184,189,234,231,139,82,57,229,175,17,178,28,7,163,135,40,13,202,84,164,214,100,158,249,215,55,250,160,67,74,194,95,162,182,9],"expectedCoordinator":28},{"name":"generated-047-size-101","seedInt64":"-2022353939353041033","attemptNumber":1484793676,"members":[90,6,158,120,241,209,21,146,119,102,9,107,39,169,125,4,182,183,175,233,57,231,154,81,238,187,255,74,80,181,51,215,29,113,194,246,56,73,196,244,213,235,48,223,61,179,60,108,127,30,19,174,180,218,243,220,16,230,14,70,136,79,96,177,53,207,168,249,166,211,242,162,159,216,160,78,88,101,208,54,201,141,63,5,149,49,240,114,239,98,253,105,155,86,254,134,93,106,75,118,18],"expectedCoordinator":78},{"name":"generated-048-size-4","seedInt64":"-6135348390758098046","attemptNumber":5,"members":[179,205,13,1],"expectedCoordinator":13},{"name":"generated-049-size-11","seedInt64":"-8240015165915168050","attemptNumber":53824,"members":[40,1,75,13,149,165,38,28,188,151,81],"expectedCoordinator":165},{"name":"generated-050-size-88","seedInt64":"-6499412993579667673","attemptNumber":2758221587,"members":[22,183,101,60,167,93,243,150,250,17,225,7,159,127,192,205,34,190,2,15,181,236,249,96,27,134,197,86,77,72,90,48,222,156,151,61,128,133,155,116,28,16,215,168,139,57,110,218,196,131,135,114,237,53,234,245,213,70,76,89,217,202,71,62,232,193,54,230,6,198,104,88,68,251,21,123,141,49,23,67,235,200,223,189,206,165,80,98],"expectedCoordinator":57},{"name":"generated-051-size-123","seedInt64":"-8615701537821470870","attemptNumber":5,"members":[82,228,64,92,79,245,25,119,194,93,162,204,74,121,53,137,190,169,196,71,201,76,221,183,212,186,180,150,30,170,242,24,218,62,200,207,72,161,34,146,114,152,13,52,178,145,29,166,193,253,239,141,107,43,236,191,154,205,118,219,100,173,65,108,51,117,73,98,149,247,23,237,44,110,158,85,17,177,41,155,97,99,56,206,21,33,163,116,87,133,214,3,49,188,32,36,104,31,68,1,225,179,4,46,127,246,109,86,70,254,69,39,26,20,138,123,135,176,18,229,209,11,9],"expectedCoordinator":44},{"name":"generated-052-size-4","seedInt64":"6085274250026097870","attemptNumber":47471,"members":[190,240,44,201],"expectedCoordinator":201},{"name":"generated-053-size-6","seedInt64":"-7983661871081620375","attemptNumber":1925663801,"members":[60,34,146,64,90,208],"expectedCoordinator":146},{"name":"generated-054-size-80","seedInt64":"-2109680148525604779","attemptNumber":1,"members":[160,231,10,247,49,78,208,248,31,170,216,134,174,52,152,233,232,179,103,149,235,166,252,145,70,158,22,220,127,210,112,147,137,76,96,80,202,243,94,229,211,2,66,159,107,142,122,242,180,37,114,25,95,27,62,71,47,140,193,165,217,148,244,46,254,13,156,81,223,177,226,59,176,90,124,246,89,172,182,238],"expectedCoordinator":179},{"name":"generated-055-size-210","seedInt64":"6285159151798525360","attemptNumber":35344,"members":[157,155,235,83,46,210,87,188,191,196,229,65,180,119,79,101,21,193,26,27,200,216,243,255,198,175,126,56,104,187,6,111,19,241,18,135,8,42,39,173,232,51,253,192,141,189,211,234,118,176,184,55,190,62,209,148,163,167,236,166,91,53,63,38,68,244,22,133,248,76,218,165,31,213,114,227,47,144,158,136,162,149,146,4,138,85,80,154,48,124,215,220,161,99,54,43,106,233,71,230,69,7,105,123,3,64,30,29,89,67,109,70,61,24,102,181,41,246,150,249,152,171,172,12,203,116,251,219,97,151,212,170,185,174,147,40,140,50,45,224,32,182,100,247,34,195,228,204,2,59,237,81,92,137,78,226,125,239,96,9,139,214,134,60,145,103,115,164,74,23,16,159,93,72,20,1,108,121,231,82,128,127,143,206,201,86,242,33,90,15,88,217,132,95,202,225,168,112,5,179,156,245,35,84,160,58,37,14,207,110],"expectedCoordinator":55},{"name":"generated-056-size-5","seedInt64":"3281694088511105297","attemptNumber":1737793213,"members":[33,232,16,56,225],"expectedCoordinator":232},{"name":"generated-057-size-36","seedInt64":"-4037002270589620632","attemptNumber":4,"members":[213,2,228,143,254,70,16,200,230,116,65,245,142,33,141,166,246,237,120,26,36,34,234,168,108,186,121,170,177,215,149,173,232,109,148,132],"expectedCoordinator":200},{"name":"generated-058-size-69","seedInt64":"-3215270095027476444","attemptNumber":13598,"members":[120,248,225,217,108,191,156,46,161,160,1,87,142,214,234,193,73,203,60,122,116,112,202,170,151,123,45,107,132,92,182,37,139,43,26,208,244,223,181,117,189,33,162,211,72,36,18,249,166,190,153,69,24,53,238,199,179,118,220,51,247,119,105,100,130,29,49,206,106],"expectedCoordinator":60},{"name":"generated-059-size-215","seedInt64":"-5479145113724476750","attemptNumber":2937994337,"members":[128,244,137,172,14,165,96,123,138,176,217,140,242,224,119,177,250,185,150,166,226,113,47,232,161,187,141,192,120,182,33,55,174,220,198,74,168,4,239,227,229,79,68,67,107,135,25,6,142,28,164,7,125,156,204,215,19,233,31,88,44,15,173,155,153,110,189,151,126,160,254,221,178,114,81,116,201,48,210,222,23,20,251,72,56,97,71,57,200,194,92,26,10,248,77,184,181,218,32,60,112,93,234,129,98,188,49,1,78,152,11,34,179,171,139,216,53,52,228,70,195,82,106,238,43,102,183,65,3,105,63,186,205,46,253,45,41,115,130,223,83,66,180,2,131,91,207,109,59,35,87,246,209,211,145,9,162,95,147,85,219,84,136,231,69,12,203,146,90,75,37,22,124,29,154,235,255,158,38,103,245,76,213,94,117,159,132,16,100,148,30,149,214,111,212,199,121,61,240,163,80,99,169,8,206,144,243,50,252,236,27,196,202,18,134],"expectedCoordinator":41},{"name":"generated-060-size-4","seedInt64":"-403496108953467505","attemptNumber":7,"members":[34,117,232,153],"expectedCoordinator":153},{"name":"generated-061-size-34","seedInt64":"5506762533289306830","attemptNumber":47726,"members":[63,87,47,9,61,71,104,125,147,38,83,146,194,105,67,166,62,46,231,84,75,132,113,43,69,187,22,170,73,139,95,164,89,138],"expectedCoordinator":105},{"name":"generated-062-size-85","seedInt64":"-4191138463886145665","attemptNumber":3556973975,"members":[142,71,223,171,134,150,72,125,11,251,38,30,88,33,199,163,204,120,5,211,60,112,43,96,189,213,93,217,227,65,232,58,113,89,85,253,228,50,45,200,158,172,20,82,240,139,151,122,26,169,42,153,174,73,167,78,46,47,246,70,140,59,210,235,87,208,234,133,17,248,1,54,124,14,249,205,97,216,51,106,123,61,57,219,148],"expectedCoordinator":51},{"name":"generated-063-size-210","seedInt64":"-201899272265178044","attemptNumber":6,"members":[16,122,42,177,154,224,198,195,23,111,233,26,118,183,67,167,171,194,152,144,91,123,134,13,51,199,39,18,8,237,31,95,214,35,242,89,150,179,82,4,229,208,44,176,15,206,79,185,228,108,33,255,25,52,46,253,182,29,187,114,101,153,71,157,139,249,142,169,43,243,197,66,164,68,207,132,75,160,119,170,219,128,184,19,166,6,9,48,50,148,173,213,93,116,203,70,222,64,190,136,49,90,28,47,1,40,110,191,115,34,104,159,103,10,172,3,161,211,215,41,32,162,193,2,30,77,24,251,252,17,57,196,245,45,178,149,58,146,143,192,60,72,92,155,59,145,225,189,186,201,210,244,12,133,247,241,127,85,137,81,220,61,239,231,107,100,73,223,250,65,7,138,212,62,105,180,200,112,37,218,226,63,27,21,130,181,234,5,204,106,109,156,126,175,124,168,120,88,230,209,76,121,96,217,238,140,87,53,165,141],"expectedCoordinator":161},{"name":"generated-064-size-3","seedInt64":"-5111265844278360500","attemptNumber":20775,"members":[243,95,245],"expectedCoordinator":95},{"name":"generated-065-size-35","seedInt64":"-394007837480341028","attemptNumber":2878526896,"members":[49,173,43,141,38,22,172,37,90,58,119,190,80,28,144,112,24,17,1,85,15,45,176,220,105,245,16,77,237,65,123,154,255,227,83],"expectedCoordinator":105},{"name":"generated-066-size-78","seedInt64":"-1519648350433715887","attemptNumber":6,"members":[67,86,171,186,215,63,213,152,222,147,231,169,225,140,154,241,211,72,196,80,104,216,96,76,133,89,111,157,114,34,181,214,165,230,8,71,247,88,35,53,177,244,11,44,69,26,118,159,224,18,55,75,9,170,190,239,16,91,19,167,24,200,207,250,84,93,233,184,1,107,17,188,139,252,90,57,61,130],"expectedCoordinator":225},{"name":"generated-067-size-239","seedInt64":"777876564701688755","attemptNumber":64197,"members":[111,11,188,57,51,144,160,182,249,113,49,238,52,70,152,254,168,143,130,132,175,208,4,47,83,24,140,27,206,43,8,109,252,248,104,219,231,250,184,232,171,116,119,74,234,58,102,255,107,176,19,56,88,67,120,181,72,147,156,189,30,235,38,1,170,129,92,159,18,216,69,139,59,90,3,94,141,40,203,71,12,205,108,227,86,136,200,29,61,229,41,53,50,212,16,214,35,134,101,217,225,26,218,190,2,211,60,73,131,23,68,78,76,209,93,128,197,100,9,221,150,98,55,245,154,82,213,115,48,122,103,177,237,201,222,75,99,194,145,65,137,114,97,193,96,125,228,240,172,63,149,253,246,110,186,239,161,210,22,118,44,25,123,117,14,185,155,251,87,5,89,167,223,187,54,162,135,112,230,183,37,233,158,79,192,198,33,195,164,148,215,146,62,31,236,220,180,13,163,244,127,224,46,81,28,17,34,126,247,178,166,142,15,169,91,106,207,151,36,153,77,66,173,202,6,45,84,32,20,199,243,179,64,105,7,121,157,242,95],"expectedCoordinator":59},{"name":"generated-068-size-1","seedInt64":"5361330154551456030","attemptNumber":285010537,"members":[231],"expectedCoordinator":231},{"name":"generated-069-size-51","seedInt64":"5954310445796927439","attemptNumber":2,"members":[156,27,153,70,23,59,247,43,146,223,250,241,129,244,159,234,51,205,155,109,170,187,147,131,211,185,182,149,53,32,119,61,81,198,1,171,11,100,8,116,202,210,22,5,103,206,229,34,86,37,97],"expectedCoordinator":97},{"name":"generated-070-size-76","seedInt64":"-982993966154804886","attemptNumber":62245,"members":[1,228,114,255,20,56,87,50,104,76,96,248,166,69,121,233,117,106,175,129,172,55,237,252,201,73,217,170,239,158,159,178,47,38,65,131,66,219,44,234,226,10,86,245,155,112,134,249,179,74,82,247,119,207,198,191,59,4,156,203,71,176,70,187,43,194,36,78,212,222,29,240,127,133,157,154],"expectedCoordinator":255},{"name":"generated-071-size-148","seedInt64":"1792812700471506768","attemptNumber":4021902631,"members":[153,113,241,120,76,57,211,151,56,8,219,204,28,13,51,66,185,94,87,128,217,17,107,137,158,101,198,226,1,233,172,207,160,125,91,103,178,230,12,71,78,250,216,183,215,30,19,149,209,6,210,194,150,236,156,121,202,152,201,197,114,65,45,44,38,42,74,69,248,24,118,174,147,40,39,22,251,254,164,155,224,23,243,163,189,232,165,99,177,171,188,52,98,228,252,139,221,104,234,247,21,82,55,218,64,159,186,111,146,79,32,223,246,187,43,41,7,206,140,244,245,4,110,50,167,196,142,136,242,166,9,77,84,75,145,67,27,14,143,92,5,83,88,138,53,48,60,108],"expectedCoordinator":189},{"name":"generated-072-size-2","seedInt64":"739432769285166422","attemptNumber":4,"members":[170,248],"expectedCoordinator":248},{"name":"generated-073-size-7","seedInt64":"-5799010697825483380","attemptNumber":58325,"members":[164,177,92,45,51,49,154],"expectedCoordinator":164},{"name":"generated-074-size-52","seedInt64":"4276450972845508716","attemptNumber":1628425789,"members":[248,235,12,152,173,133,114,243,130,91,197,210,34,56,111,27,17,253,126,75,172,55,141,215,134,73,23,37,128,19,61,26,38,162,166,36,131,127,41,153,49,222,249,218,191,53,206,177,145,189,43,138],"expectedCoordinator":37},{"name":"generated-075-size-131","seedInt64":"-9116009320288614256","attemptNumber":2,"members":[133,31,129,225,204,101,216,146,72,183,117,205,143,202,14,252,74,170,88,30,34,156,219,87,195,135,18,56,9,144,212,197,21,142,218,168,201,76,179,36,86,236,1,113,237,75,17,66,85,196,238,4,60,121,255,15,161,159,130,63,167,149,244,124,194,160,190,107,210,62,223,166,235,46,186,3,246,114,116,5,61,13,10,209,68,180,148,47,71,27,175,54,43,16,208,242,23,37,91,200,213,145,134,182,53,77,64,229,228,172,226,127,177,140,45,11,191,211,155,243,7,52,227,222,89,19,185,138,174,251,128],"expectedCoordinator":15},{"name":"generated-076-size-5","seedInt64":"1967037392293916423","attemptNumber":32183,"members":[143,17,140,73,200],"expectedCoordinator":143},{"name":"generated-077-size-47","seedInt64":"722756409953594652","attemptNumber":736868784,"members":[65,45,99,248,34,9,118,80,111,24,18,195,134,154,220,231,40,46,200,4,233,196,75,216,243,12,77,29,74,1,15,89,205,22,182,247,186,174,147,253,68,213,119,151,107,106,153],"expectedCoordinator":118},{"name":"generated-078-size-90","seedInt64":"5847791647644196462","attemptNumber":3,"members":[25,54,190,142,15,72,33,226,146,3,46,11,28,106,202,89,110,30,116,162,132,102,151,168,124,31,49,167,88,180,63,217,155,58,179,79,67,232,196,56,38,225,52,13,20,208,42,153,134,70,59,191,238,18,60,214,139,178,48,198,235,118,157,81,97,19,184,23,144,253,40,100,204,5,161,176,227,130,188,205,87,82,212,27,147,21,186,145,174,194],"expectedCoordinator":161},{"name":"generated-079-size-237","seedInt64":"-7318126917914551043","attemptNumber":30384,"members":[230,80,103,8,205,237,225,181,53,174,60,123,125,94,110,220,238,55,128,101,195,239,29,97,247,134,154,46,249,163,27,165,175,3,100,219,81,197,199,51,88,76,236,73,244,71,59,38,90,130,206,234,4,75,95,117,215,1,93,250,66,135,16,43,18,189,151,105,137,202,99,177,119,107,115,42,158,2,191,226,211,201,180,157,169,227,186,179,49,11,13,104,192,245,252,207,58,166,10,19,142,224,147,102,96,89,32,233,254,86,61,23,84,240,183,25,116,79,22,156,143,50,72,70,153,41,113,98,160,37,204,111,188,222,196,228,255,92,30,203,35,118,248,243,20,15,17,83,167,12,246,210,164,168,129,194,48,26,241,223,171,184,155,132,36,9,136,6,149,200,87,7,108,112,218,231,57,54,82,47,213,216,214,68,40,52,212,91,144,124,64,161,31,193,221,253,127,85,65,63,150,24,242,148,173,5,162,138,209,176,131,146,170,45,74,121,34,44,109,77,14,56,152,140,145,120,251,114,185,141,126,178,67,39,217,62,78],"expectedCoordinator":45},{"name":"generated-080-size-1","seedInt64":"-5553512206672994256","attemptNumber":2001471067,"members":[25],"expectedCoordinator":25},{"name":"generated-081-size-34","seedInt64":"-754910302847408272","attemptNumber":7,"members":[158,115,10,219,111,61,163,90,59,53,222,194,91,13,5,155,189,198,146,179,126,226,132,232,201,167,70,87,143,98,212,35,156,154],"expectedCoordinator":156},{"name":"generated-082-size-63","seedInt64":"499045964822816795","attemptNumber":27768,"members":[199,92,35,212,238,226,179,65,227,59,1,236,108,132,196,220,215,5,245,139,194,209,101,32,81,96,68,152,82,31,62,151,4,8,90,150,15,239,200,154,42,137,69,124,129,180,105,134,250,178,89,253,115,21,64,207,246,95,162,78,203,252,153],"expectedCoordinator":162},{"name":"generated-083-size-197","seedInt64":"8854687612890123515","attemptNumber":3363899948,"members":[61,193,113,35,149,43,100,26,195,151,254,79,83,192,24,146,161,167,18,102,28,197,46,48,216,203,1,198,227,96,67,248,199,59,29,16,94,81,162,65,51,7,139,157,171,68,206,165,101,137,239,23,242,19,148,106,240,55,20,44,49,135,82,191,234,22,170,190,178,214,230,50,189,84,174,78,36,109,215,152,217,221,183,211,213,186,154,210,229,40,110,52,70,134,243,91,88,150,188,232,219,117,114,104,54,173,12,136,255,182,6,41,231,76,246,176,120,237,64,15,241,233,252,209,251,138,45,87,204,111,247,3,69,175,66,205,53,85,212,143,74,31,108,144,75,63,56,223,220,145,92,14,58,156,177,97,25,245,235,153,95,2,86,131,201,184,112,140,90,194,128,124,38,253,163,39,155,236,130,141,238,72,9,168,226,42,250,37,119,107,244,122,34,5,60,228,159],"expectedCoordinator":90},{"name":"generated-084-size-3","seedInt64":"-808564650378332400","attemptNumber":0,"members":[139,102,159],"expectedCoordinator":159},{"name":"generated-085-size-18","seedInt64":"6937620626493891812","attemptNumber":55636,"members":[246,37,253,177,182,220,59,48,247,69,191,133,146,242,172,189,107,139],"expectedCoordinator":247},{"name":"generated-086-size-61","seedInt64":"-3080056330586482310","attemptNumber":951733443,"members":[138,174,215,18,69,47,79,136,9,23,96,182,7,194,90,16,10,108,106,40,86,232,74,200,157,228,77,12,203,148,152,248,110,253,132,20,143,14,244,123,45,225,137,168,210,85,1,92,17,145,153,82,87,187,61,181,127,176,246,233,109],"expectedCoordinator":109},{"name":"generated-087-size-174","seedInt64":"7923155091191227573","attemptNumber":6,"members":[128,31,100,172,22,219,234,9,249,85,21,73,202,242,255,200,253,207,138,15,51,74,189,8,115,193,238,105,159,246,162,168,240,101,95,18,212,217,120,169,104,245,58,218,223,216,49,68,129,97,125,183,130,241,24,45,121,171,206,210,224,61,243,225,41,186,62,194,14,93,67,182,250,233,174,39,244,44,63,70,136,209,1,140,30,235,131,46,126,111,10,220,197,196,208,60,213,237,215,26,191,153,6,91,198,25,116,148,92,86,64,82,75,199,11,222,231,119,180,150,33,179,12,170,158,110,55,7,4,23,36,142,2,139,149,188,203,107,146,42,94,228,211,201,71,59,34,143,90,108,205,72,16,137,166,127,66,221,98,161,29,147,167,112,187,190,252,53,84,173,20,229,144,124],"expectedCoordinator":25},{"name":"generated-088-size-4","seedInt64":"-7772075196162702918","attemptNumber":6933,"members":[151,174,219,138],"expectedCoordinator":219},{"name":"generated-089-size-6","seedInt64":"4324138335998437962","attemptNumber":2520292755,"members":[171,148,196,51,43,120],"expectedCoordinator":43},{"name":"generated-090-size-96","seedInt64":"-7108590396456967036","attemptNumber":3,"members":[251,138,241,224,56,247,213,237,4,40,57,124,122,104,131,173,254,90,22,92,79,81,232,64,221,242,140,248,121,148,154,27,217,142,151,46,86,106,202,170,190,74,50,83,207,105,132,212,66,14,156,200,116,228,205,54,150,88,69,52,186,35,112,62,162,165,225,39,109,126,214,249,169,177,172,123,243,137,128,78,181,10,159,171,178,70,28,146,115,114,176,204,185,51,3,25],"expectedCoordinator":207},{"name":"generated-091-size-206","seedInt64":"5699283716282044127","attemptNumber":9927,"members":[98,201,195,209,239,160,62,166,87,26,93,127,50,52,197,72,39,255,163,92,154,44,227,205,204,141,6,222,200,155,46,144,175,250,104,213,220,19,53,61,86,229,108,188,171,123,69,238,36,178,225,216,81,115,34,212,210,13,106,192,32,194,3,45,137,139,147,206,226,151,97,136,116,90,189,78,186,254,143,111,203,237,198,109,94,15,242,161,10,83,101,122,95,253,132,114,24,102,134,54,133,77,23,40,60,57,89,28,135,162,183,219,48,241,215,180,76,187,142,21,172,14,156,145,252,177,16,121,35,228,193,25,55,174,51,190,199,221,173,7,17,131,125,224,236,249,31,202,70,58,66,150,240,128,41,164,71,84,4,149,38,231,27,244,82,246,74,158,105,184,185,11,233,168,159,8,165,218,18,42,2,179,112,124,75,148,33,22,117,37,85,110,79,214,29,5,113,129,65,59,217,20,88,47,99,196],"expectedCoordinator":218},{"name":"generated-092-size-1","seedInt64":"3308329852372527413","attemptNumber":459300022,"members":[242],"expectedCoordinator":242},{"name":"generated-093-size-36","seedInt64":"869278551896482336","attemptNumber":1,"members":[235,213,111,63,29,199,232,46,86,91,233,17,228,71,204,189,141,52,38,25,138,173,27,96,150,175,20,54,203,238,208,37,182,60,83,99],"expectedCoordinator":91},{"name":"generated-094-size-64","seedInt64":"437181317485813772","attemptNumber":5250,"members":[162,23,151,25,223,99,121,18,108,28,220,101,31,147,22,154,239,113,187,251,143,89,82,118,214,148,175,69,123,95,180,97,111,38,227,16,3,81,224,98,240,209,141,7,65,21,189,248,39,188,1,152,145,73,156,237,32,139,46,11,234,160,75,62],"expectedCoordinator":1},{"name":"generated-095-size-158","seedInt64":"-2342632002888957506","attemptNumber":430509936,"members":[24,96,93,89,194,139,195,207,13,236,5,47,100,131,238,220,204,164,176,247,144,92,233,64,46,199,19,237,3,103,248,198,241,254,42,115,185,158,75,128,1,25,52,181,107,188,145,67,180,151,224,81,23,66,252,132,149,40,79,250,44,187,99,255,170,148,57,174,49,116,172,37,135,130,183,136,163,166,157,203,206,77,16,30,161,14,41,182,6,213,4,110,21,78,125,35,74,73,222,245,189,226,138,221,160,55,31,87,200,231,8,208,196,253,83,127,118,65,212,119,7,142,165,91,225,234,85,134,229,239,104,223,159,28,43,106,53,56,88,10,129,137,249,95,211,230,242,39,147,178,216,32,11,63,175,9,251,27],"expectedCoordinator":110},{"name":"generated-096-size-3","seedInt64":"-1409214535466672362","attemptNumber":1,"members":[165,41,46],"expectedCoordinator":165},{"name":"generated-097-size-41","seedInt64":"-9040160217759173814","attemptNumber":34752,"members":[81,117,58,120,238,237,78,141,223,110,51,177,64,243,229,170,164,160,73,252,233,126,184,130,128,98,240,175,217,86,129,9,80,59,91,79,144,134,215,104,63],"expectedCoordinator":243},{"name":"generated-098-size-86","seedInt64":"-8297759448010871175","attemptNumber":1077414318,"members":[143,226,37,155,68,28,241,252,224,205,72,139,19,56,61,174,165,159,71,3,14,63,66,39,98,178,197,181,228,134,55,157,160,91,245,38,207,120,195,58,43,196,18,220,109,93,10,24,221,133,249,189,129,127,239,192,17,246,218,113,73,136,41,13,46,214,119,31,244,21,51,149,243,121,9,227,202,199,59,188,251,67,242,219,99,74],"expectedCoordinator":14},{"name":"generated-099-size-167","seedInt64":"-2284973494040658","attemptNumber":3,"members":[43,65,179,97,105,69,162,48,194,114,135,115,174,192,167,93,163,200,112,242,36,15,236,46,23,95,88,238,155,215,30,4,134,128,124,233,217,224,229,156,29,10,143,152,90,237,211,232,188,35,111,172,118,182,16,132,171,26,67,81,187,5,178,117,253,51,186,38,140,235,185,116,198,119,212,193,52,49,168,55,108,154,37,71,122,208,133,254,47,54,76,33,214,21,78,153,68,209,50,165,234,113,216,191,196,62,96,137,89,40,125,158,225,197,164,70,84,255,32,19,80,218,22,110,31,166,189,184,3,7,202,157,123,120,219,2,121,11,241,42,20,204,213,75,53,138,8,83,136,131,14,63,222,9,126,87,17,227,245,141,248,18,86,34,72,66,94],"expectedCoordinator":134},{"name":"generated-100-size-5","seedInt64":"896812837700587291","attemptNumber":10942,"members":[41,48,24,186,153],"expectedCoordinator":41},{"name":"generated-101-size-47","seedInt64":"5195256321549385485","attemptNumber":1557219140,"members":[159,96,103,11,17,151,99,83,226,119,82,8,169,233,89,113,12,108,65,142,136,239,211,42,207,46,171,18,164,38,57,107,51,191,114,22,241,123,237,170,139,138,23,131,173,210,182],"expectedCoordinator":151},{"name":"generated-102-size-96","seedInt64":"-5544863465523421947","attemptNumber":4,"members":[170,14,222,124,65,7,111,99,31,156,255,17,40,60,235,226,132,85,239,96,137,238,50,169,219,92,172,22,176,110,43,192,135,212,144,61,47,173,42,159,105,228,220,8,204,218,177,25,195,143,236,38,76,209,32,215,126,145,91,165,141,44,21,16,207,28,48,72,243,161,214,4,90,86,114,191,73,232,196,245,1,174,211,93,149,162,155,112,130,67,53,12,185,57,80,2],"expectedCoordinator":1},{"name":"generated-103-size-123","seedInt64":"8770007098653717238","attemptNumber":35670,"members":[207,221,8,146,173,199,15,238,110,151,239,3,227,89,59,128,114,147,192,37,232,104,98,249,88,48,121,138,201,150,170,165,131,160,167,235,198,25,224,174,86,214,123,53,196,217,51,245,253,6,124,153,190,20,12,36,139,50,92,90,96,74,41,108,205,109,234,80,68,56,130,180,209,168,52,233,156,181,117,72,134,45,229,191,66,171,226,29,169,145,251,75,120,95,115,255,172,149,76,61,43,63,84,143,21,2,133,175,236,49,144,125,159,230,200,17,242,33,247,211,179,97,193],"expectedCoordinator":109},{"name":"generated-104-size-5","seedInt64":"4264855667354723790","attemptNumber":3035280916,"members":[221,88,172,148,222],"expectedCoordinator":222},{"name":"generated-105-size-16","seedInt64":"-489523979497492863","attemptNumber":6,"members":[40,83,165,97,187,76,60,12,63,175,172,182,134,179,91,68],"expectedCoordinator":68},{"name":"generated-106-size-86","seedInt64":"3927585140628679105","attemptNumber":11163,"members":[145,132,48,250,90,69,22,78,67,240,8,167,20,140,4,166,41,154,57,208,39,215,43,89,2,80,170,185,189,173,106,203,42,210,125,212,217,34,33,247,116,225,47,242,35,172,241,244,161,26,18,207,252,92,144,6,152,227,176,59,138,23,214,65,180,87,142,29,237,130,71,148,112,239,158,14,127,134,156,85,9,12,55,162,169,164],"expectedCoordinator":207},{"name":"generated-107-size-211","seedInt64":"-3282780776666564491","attemptNumber":2427916040,"members":[117,74,1,232,62,178,143,36,173,245,142,42,254,189,126,220,57,201,50,108,243,16,10,112,182,41,52,47,193,104,151,125,146,9,44,138,155,11,29,78,94,239,197,93,58,21,200,145,95,37,237,19,60,70,137,233,128,221,76,46,170,149,32,26,215,124,121,48,14,135,248,250,97,6,30,116,5,51,213,205,98,199,53,177,7,206,227,63,216,153,234,238,105,67,22,255,107,241,157,136,20,84,165,31,150,152,166,219,171,168,54,83,167,65,34,59,49,129,88,69,38,127,45,15,130,224,180,87,188,208,66,71,2,148,101,55,120,114,86,79,28,103,123,225,3,164,147,252,172,195,203,160,122,240,222,242,113,247,176,226,25,18,192,186,218,204,132,119,115,35,12,109,61,175,75,183,39,68,141,181,174,89,131,23,223,210,246,13,163,158,249,190,217,229,40,179,77,56,228,198,196,17,144,73,81,169,161,96,85,106,118],"expectedCoordinator":217},{"name":"generated-108-size-4","seedInt64":"468282769009480890","attemptNumber":0,"members":[197,109,127,113],"expectedCoordinator":113},{"name":"generated-109-size-40","seedInt64":"589500209168864818","attemptNumber":49220,"members":[66,235,110,98,141,114,88,167,221,178,100,123,93,86,181,246,3,199,254,8,13,147,180,59,244,82,51,223,96,240,71,26,10,30,108,95,151,155,19,138],"expectedCoordinator":244},{"name":"generated-110-size-58","seedInt64":"6453479717062776292","attemptNumber":3429615405,"members":[180,253,78,217,239,53,65,172,118,234,32,225,192,83,46,244,135,165,7,25,190,246,70,199,74,181,95,232,104,50,91,159,122,48,139,93,90,28,110,250,143,126,103,150,204,44,169,147,243,175,157,81,37,168,179,41,203,163],"expectedCoordinator":172},{"name":"generated-111-size-240","seedInt64":"-2015854576496395857","attemptNumber":1,"members":[52,170,81,134,250,31,246,69,84,183,216,22,232,35,50,107,102,171,161,56,2,207,242,197,119,227,126,198,238,180,68,41,116,9,151,101,159,51,192,113,143,44,43,82,49,48,16,150,79,133,166,33,181,193,202,91,165,90,114,230,186,39,110,83,162,249,184,154,214,203,67,174,15,147,125,241,240,47,245,46,191,153,190,169,12,80,37,205,213,132,252,77,172,120,226,25,253,219,177,72,88,176,211,14,30,146,78,164,225,229,70,179,152,63,206,212,158,106,105,149,29,53,59,71,104,243,248,188,86,135,167,121,117,18,208,237,36,209,144,60,76,194,185,220,1,62,142,6,201,8,128,95,115,233,148,66,92,93,195,54,57,127,4,136,155,217,251,23,247,223,168,140,122,131,157,204,75,74,124,231,100,28,108,218,85,118,65,236,175,199,17,32,139,87,89,38,21,210,255,55,145,123,224,96,156,26,215,235,160,109,244,5,45,182,163,99,221,34,189,20,196,3,64,222,239,24,234,42,98,27,97,112,129,11,130,254,200,111,61,141],"expectedCoordinator":235},{"name":"generated-112-size-3","seedInt64":"7956552151715933926","attemptNumber":31123,"members":[188,149,223],"expectedCoordinator":149},{"name":"generated-113-size-47","seedInt64":"-4536866646715362967","attemptNumber":710310363,"members":[245,37,91,199,210,137,30,25,220,215,166,224,164,56,170,133,242,190,6,235,157,40,148,254,180,5,7,240,70,181,62,19,179,134,29,66,80,165,48,167,145,72,21,205,155,239,168],"expectedCoordinator":242},{"name":"generated-114-size-90","seedInt64":"6873027339573335067","attemptNumber":4,"members":[37,145,192,241,210,87,81,75,154,89,29,163,42,208,201,49,231,97,165,185,238,107,138,243,184,214,14,59,237,195,255,84,94,62,35,125,90,68,150,215,141,10,15,226,9,50,53,98,105,254,136,24,17,253,76,71,38,235,174,22,188,179,219,55,91,124,20,252,246,172,113,61,160,27,86,43,171,186,12,85,199,187,116,135,106,193,190,16,131,93],"expectedCoordinator":190},{"name":"generated-115-size-232","seedInt64":"-2372759547836105748","attemptNumber":64637,"members":[102,159,251,70,84,131,51,79,63,90,234,203,98,107,95,10,137,83,142,166,118,91,116,103,226,129,29,175,158,239,14,61,149,66,69,141,106,156,254,112,223,8,12,93,170,186,201,222,115,76,120,232,92,13,188,143,212,139,130,133,77,18,50,85,44,72,128,147,110,82,26,157,20,245,196,127,126,48,42,179,15,252,58,68,86,108,224,140,236,78,65,168,4,241,207,231,153,88,225,155,216,31,2,161,111,197,52,3,43,11,101,160,67,198,1,174,183,100,117,238,134,244,192,33,138,119,248,144,173,167,255,230,47,6,89,215,57,195,75,55,104,181,228,210,94,36,124,152,56,176,209,227,182,240,109,123,220,229,146,49,23,60,59,46,189,202,27,7,39,73,64,193,22,122,9,148,30,178,218,214,243,81,35,250,145,237,37,28,99,24,38,74,249,165,5,208,221,62,194,150,199,71,135,21,190,136,80,219,184,41,97,121,87,19,154,213,32,217,211,105,185,180,114,233,96,34,151,177,45,206,17,205],"expectedCoordinator":241},{"name":"generated-116-size-1","seedInt64":"2987736078746814364","attemptNumber":3403786515,"members":[163],"expectedCoordinator":163},{"name":"generated-117-size-20","seedInt64":"-3262731345082553583","attemptNumber":0,"members":[119,215,113,10,116,118,127,146,189,142,190,202,25,47,31,174,80,77,165,129],"expectedCoordinator":165},{"name":"generated-118-size-96","seedInt64":"-4255045493980341817","attemptNumber":47439,"members":[130,249,161,168,196,218,157,67,21,20,90,139,30,80,187,224,38,203,205,144,28,242,12,226,158,159,209,82,106,207,29,165,79,41,184,115,36,133,68,33,35,93,234,78,177,137,175,171,11,253,180,160,225,197,98,101,235,240,5,173,189,233,166,131,119,210,65,71,49,60,26,75,151,51,117,238,181,58,59,103,54,152,251,40,135,37,52,194,204,47,179,15,143,136,170,34],"expectedCoordinator":189},{"name":"generated-119-size-131","seedInt64":"-2630668850182605260","attemptNumber":3538401091,"members":[220,214,190,133,109,35,117,96,161,206,223,143,62,116,89,134,225,1,218,72,240,182,249,237,123,11,127,121,129,197,175,231,154,157,26,13,196,106,144,104,171,12,139,194,120,235,77,9,212,93,228,39,158,6,126,245,189,178,167,66,204,34,99,172,40,53,57,41,163,246,124,141,68,30,17,253,71,37,54,222,165,105,138,227,151,65,135,122,70,209,184,111,19,46,236,76,16,215,200,83,32,119,224,226,29,131,98,146,250,176,170,149,5,43,103,73,110,48,202,248,203,113,118,50,64,2,179,128,148,181,92],"expectedCoordinator":40},{"name":"generated-120-size-3","seedInt64":"4007936685134953","attemptNumber":1,"members":[40,155,220],"expectedCoordinator":220},{"name":"generated-121-size-20","seedInt64":"-6866019563340355907","attemptNumber":62275,"members":[17,243,92,123,111,150,168,189,241,139,180,86,130,142,136,78,157,204,205,96],"expectedCoordinator":92},{"name":"generated-122-size-91","seedInt64":"3118597197668961341","attemptNumber":3226070506,"members":[22,108,35,138,152,39,54,153,219,178,128,119,106,5,148,139,249,151,125,43,230,129,58,107,252,78,42,206,6,65,30,246,77,240,23,134,117,51,60,198,150,158,241,157,31,55,204,166,75,122,174,100,26,69,203,248,221,25,33,40,92,238,91,133,154,1,19,200,88,226,27,87,149,57,189,38,4,59,89,233,210,131,121,41,9,141,165,61,13,232,137],"expectedCoordinator":249},{"name":"generated-123-size-141","seedInt64":"1664817802804734561","attemptNumber":2,"members":[27,200,152,124,5,143,92,242,247,236,231,171,226,233,245,181,189,149,4,216,155,178,196,157,243,217,132,214,235,28,61,213,141,71,59,250,246,72,97,12,142,193,160,86,136,234,45,230,206,253,110,20,106,48,197,182,203,1,37,111,146,115,129,22,215,79,239,210,70,173,88,201,225,90,162,53,118,16,60,164,135,8,42,39,109,126,89,237,184,188,148,100,238,207,229,19,147,98,145,117,31,113,169,222,224,119,50,58,163,56,77,47,174,63,209,49,15,93,13,183,104,116,170,83,66,137,80,151,177,251,108,228,219,192,175,64,186,76,40,254,227],"expectedCoordinator":200},{"name":"generated-124-size-2","seedInt64":"-2806538414172921050","attemptNumber":49055,"members":[213,42],"expectedCoordinator":213},{"name":"generated-125-size-46","seedInt64":"-7671032942243247828","attemptNumber":4007891092,"members":[94,87,236,60,192,51,32,123,170,59,99,45,37,226,18,34,141,197,135,159,17,63,81,9,61,64,27,215,29,36,108,214,219,119,112,151,208,82,209,12,181,253,65,243,143,92],"expectedCoordinator":81},{"name":"generated-126-size-86","seedInt64":"-679015987674837067","attemptNumber":1,"members":[55,221,30,255,35,179,6,150,117,131,133,63,174,210,211,119,86,87,142,254,156,193,58,3,178,171,190,52,204,67,64,9,225,19,224,132,200,20,138,109,115,53,189,110,146,246,219,227,29,71,21,158,166,195,12,54,50,101,103,141,56,167,207,184,47,82,148,39,69,147,239,222,155,154,244,41,93,46,202,237,187,78,243,196,197,245],"expectedCoordinator":53},{"name":"generated-127-size-114","seedInt64":"-1110729648211256631","attemptNumber":34208,"members":[153,162,216,121,129,252,106,193,112,13,78,171,35,21,192,82,152,34,18,197,48,203,194,80,23,185,108,39,94,81,4,188,99,199,137,234,176,247,17,95,102,231,66,132,246,179,86,67,44,167,30,68,84,151,180,240,187,114,134,62,207,211,92,227,8,232,100,46,169,65,172,156,204,191,205,85,116,64,235,170,159,29,175,3,213,24,54,52,177,107,51,14,76,37,174,138,115,245,241,206,160,242,122,83,27,58,183,228,209,142,161,75,38,255],"expectedCoordinator":162},{"name":"generated-128-size-5","seedInt64":"-2399282708863319109","attemptNumber":3534437779,"members":[227,108,18,251,73],"expectedCoordinator":108},{"name":"generated-129-size-29","seedInt64":"441644865197136334","attemptNumber":5,"members":[66,184,25,202,152,146,83,210,52,220,198,93,6,26,76,91,233,80,185,218,19,241,45,209,31,69,192,82,157],"expectedCoordinator":83},{"name":"generated-130-size-70","seedInt64":"-2963717690369327290","attemptNumber":43347,"members":[79,85,211,198,36,186,130,231,234,112,195,208,243,166,87,209,246,60,132,235,68,167,5,177,39,237,183,233,170,142,218,155,173,126,191,175,56,116,80,96,217,48,229,194,70,255,105,1,144,160,121,49,125,102,32,76,129,77,109,74,225,192,201,133,75,239,92,185,165,242],"expectedCoordinator":166},{"name":"generated-131-size-232","seedInt64":"2220690960821665440","attemptNumber":1622707130,"members":[101,69,143,247,177,42,250,150,217,19,21,61,162,199,28,95,216,119,73,115,248,108,213,170,99,141,165,215,59,74,106,54,229,110,195,172,179,241,17,2,144,45,222,171,124,55,34,26,206,223,90,32,168,81,109,79,107,18,20,173,201,193,129,23,75,133,227,70,57,76,218,50,130,205,185,146,139,191,98,104,183,198,87,102,194,175,243,167,204,72,132,3,121,64,37,84,118,169,105,94,16,156,161,12,196,164,125,58,8,138,113,116,252,112,6,10,221,127,160,97,214,52,65,31,231,53,14,147,49,24,253,255,60,123,157,4,44,78,39,9,100,111,203,237,239,174,197,77,89,80,245,184,140,153,85,68,163,224,82,225,5,48,211,91,249,178,1,145,251,137,158,155,219,92,62,136,208,93,230,83,151,30,38,189,33,180,212,96,7,232,36,159,190,207,27,41,242,120,47,228,29,56,148,202,11,22,43,15,67,88,246,86,220,40,63,135,51,154,126,35,114,181,25,188,233,152,238,236,192,234,235,122],"expectedCoordinator":218},{"name":"generated-132-size-4","seedInt64":"3936019971243938880","attemptNumber":6,"members":[7,184,24,163],"expectedCoordinator":7},{"name":"generated-133-size-34","seedInt64":"6002883366149145476","attemptNumber":27992,"members":[101,53,169,241,2,233,49,119,74,171,215,160,36,77,108,222,14,6,207,95,176,238,192,91,100,80,168,29,229,153,205,120,112,109],"expectedCoordinator":153},{"name":"generated-134-size-86","seedInt64":"560321468927931761","attemptNumber":3614533710,"members":[162,90,177,163,150,128,196,114,226,93,109,174,249,14,63,218,166,132,185,60,231,16,77,85,153,200,105,161,239,125,70,67,120,44,84,91,141,39,82,165,159,142,237,86,87,9,219,169,156,80,208,40,192,68,181,244,252,24,225,25,250,173,65,213,79,42,20,10,28,116,221,130,41,118,233,143,188,230,136,95,214,178,148,209,15,186],"expectedCoordinator":178},{"name":"generated-135-size-229","seedInt64":"-4738644874070653480","attemptNumber":7,"members":[238,224,194,31,62,64,27,137,201,126,149,221,235,188,107,37,182,206,152,155,246,12,162,24,227,115,118,139,106,53,113,123,134,132,141,93,197,54,236,63,100,180,192,40,229,193,168,214,5,13,25,17,161,77,245,184,81,34,219,75,217,239,213,156,97,82,251,187,243,87,178,211,29,207,181,19,95,209,116,230,249,196,127,11,232,101,52,226,189,183,74,250,198,151,216,1,14,253,7,231,167,96,35,103,190,208,240,2,90,210,70,120,6,218,195,104,254,124,147,140,88,55,110,72,22,83,170,172,122,244,142,68,154,121,18,42,71,38,28,128,255,109,146,94,86,242,133,203,45,15,98,205,212,200,3,166,102,56,76,164,175,99,36,159,204,10,39,163,89,131,111,92,233,73,61,153,114,23,30,148,50,241,157,51,169,248,191,58,135,176,179,160,247,117,8,199,185,158,33,186,225,145,215,237,41,222,144,78,59,16,91,4,143,43,202,150,66,26,20,125,119,138,79,136,84,171,223,105,47],"expectedCoordinator":133},{"name":"generated-136-size-5","seedInt64":"-8441601396195031722","attemptNumber":24454,"members":[119,232,132,198,15],"expectedCoordinator":119},{"name":"generated-137-size-20","seedInt64":"5384059453162266222","attemptNumber":900032409,"members":[135,190,20,125,221,189,37,172,136,131,101,27,15,228,226,60,244,67,230,71],"expectedCoordinator":189},{"name":"generated-138-size-96","seedInt64":"-4889012937073914130","attemptNumber":3,"members":[42,210,38,5,8,56,128,39,83,212,188,110,253,116,157,71,2,3,120,43,96,88,132,23,21,100,93,9,46,230,102,97,80,199,187,205,245,33,36,81,235,86,191,166,249,179,53,178,18,59,87,250,233,239,255,219,74,19,13,15,138,69,121,254,52,14,237,16,85,231,203,213,24,224,31,106,209,28,227,202,48,142,105,170,66,11,49,20,122,27,90,41,232,240,113,78],"expectedCoordinator":138},{"name":"generated-139-size-197","seedInt64":"-3996304714434985212","attemptNumber":47596,"members":[95,21,209,225,244,124,63,82,191,8,31,89,212,30,144,162,250,105,137,153,228,126,157,152,223,255,40,52,159,218,9,198,83,87,207,2,140,211,133,177,182,99,142,116,164,248,43,123,64,23,109,143,70,51,145,136,187,61,178,156,104,19,119,219,201,195,102,176,190,131,73,80,138,125,100,220,132,7,58,38,171,130,39,33,76,4,217,215,135,245,236,175,246,158,36,69,221,150,128,108,17,117,53,237,68,118,37,66,115,110,181,75,41,253,251,149,97,86,185,59,13,111,189,243,139,134,122,18,231,165,179,25,224,27,197,196,88,114,168,5,49,214,235,167,229,113,186,249,54,163,239,72,155,24,180,199,46,173,79,15,1,184,226,208,121,254,28,120,232,240,20,90,172,47,56,193,107,26,148,106,103,11,85,169,233,129,78,45,6,10,192,14,141,222,32,42,200],"expectedCoordinator":129},{"name":"generated-140-size-4","seedInt64":"6941898763687336332","attemptNumber":4180205159,"members":[181,114,109,236],"expectedCoordinator":181},{"name":"generated-141-size-21","seedInt64":"3745590442053443273","attemptNumber":6,"members":[166,255,68,45,254,150,203,154,168,186,200,67,42,227,159,108,69,221,213,113,88],"expectedCoordinator":227},{"name":"generated-142-size-72","seedInt64":"-1934711817258154969","attemptNumber":49030,"members":[23,55,216,43,221,64,117,192,166,75,86,82,106,31,108,67,156,220,32,104,65,98,172,229,115,167,110,53,130,228,142,84,213,247,231,159,144,153,158,17,30,223,243,233,122,226,93,21,69,198,203,180,78,3,125,42,79,145,25,196,24,97,185,136,46,39,94,87,89,59,129,100],"expectedCoordinator":196},{"name":"generated-143-size-236","seedInt64":"7576952677858728614","attemptNumber":2905859179,"members":[65,124,188,2,223,66,57,158,174,108,162,93,30,228,219,121,100,144,36,18,242,182,169,87,119,73,246,107,151,216,135,64,71,189,104,229,178,114,96,252,125,173,163,101,215,14,35,139,192,232,112,105,210,131,167,177,196,165,160,225,44,195,95,141,198,166,164,148,184,147,54,128,58,159,157,122,175,39,152,208,238,202,118,155,5,113,218,133,224,76,15,209,120,21,4,149,103,1,204,47,161,92,75,6,129,180,16,67,136,91,111,123,243,83,27,37,146,38,183,41,212,72,211,191,213,172,52,154,26,142,34,187,221,194,49,60,10,168,3,190,179,241,62,42,51,235,98,84,12,31,185,63,82,134,170,77,70,156,171,78,214,176,200,199,24,110,205,240,29,48,234,17,239,150,13,143,88,245,244,116,11,236,109,255,81,50,186,89,46,249,237,7,220,137,79,74,53,22,68,145,140,55,203,19,153,90,126,193,20,248,59,45,250,227,230,99,43,181,97,80,102,25,206,138,32,226,247,8,61,127,69,40,117,28,201,233],"expectedCoordinator":28},{"name":"generated-144-size-4","seedInt64":"-282390920246923885","attemptNumber":1,"members":[192,43,110,252],"expectedCoordinator":43},{"name":"generated-145-size-22","seedInt64":"1878532552470394837","attemptNumber":58977,"members":[139,236,29,131,32,17,196,214,198,180,49,96,152,71,159,202,11,119,185,135,145,62],"expectedCoordinator":71},{"name":"generated-146-size-72","seedInt64":"-8248802214909604400","attemptNumber":3972382412,"members":[197,145,25,13,173,251,82,132,189,78,214,101,236,230,187,15,188,121,56,74,140,108,239,136,222,192,34,126,8,141,223,16,59,213,46,165,106,209,150,134,139,242,50,193,109,191,196,23,71,7,5,39,35,86,75,89,253,163,28,112,44,254,162,103,240,33,143,161,130,171,238,248],"expectedCoordinator":15},{"name":"generated-147-size-110","seedInt64":"1353775832355132400","attemptNumber":2,"members":[104,123,82,195,155,186,38,110,223,55,8,193,90,28,225,113,126,145,72,85,194,77,4,244,135,35,102,210,7,176,169,192,74,215,116,222,216,220,105,11,214,29,9,173,132,255,198,109,147,26,229,57,190,2,171,133,42,62,114,130,44,111,154,117,25,5,66,121,167,189,94,141,92,177,165,157,17,70,218,128,148,172,236,91,150,245,137,144,181,95,46,230,161,127,63,122,32,185,164,50,40,224,134,180,3,84,59,64,143,131],"expectedCoordinator":137},{"name":"generated-148-size-4","seedInt64":"-704719671913888052","attemptNumber":32746,"members":[141,24,200,148],"expectedCoordinator":200},{"name":"generated-149-size-45","seedInt64":"-8265908843381039538","attemptNumber":950210701,"members":[226,148,219,98,172,12,230,192,3,169,36,170,106,88,35,111,97,74,89,68,75,247,25,63,112,122,186,28,57,26,218,99,156,173,47,215,82,128,179,30,189,142,185,116,24],"expectedCoordinator":170},{"name":"generated-150-size-56","seedInt64":"3065043156261386358","attemptNumber":7,"members":[49,154,13,139,244,251,185,1,253,246,219,95,23,186,237,161,172,166,70,212,75,74,14,188,94,221,55,183,197,205,236,44,90,98,78,69,106,100,105,102,16,109,241,104,22,142,20,121,248,226,141,196,135,28,89,243],"expectedCoordinator":188},{"name":"generated-151-size-127","seedInt64":"-1414513725948302602","attemptNumber":8881,"members":[153,110,132,180,101,8,59,131,92,226,231,196,228,152,56,129,120,201,224,197,96,190,93,89,145,5,192,240,123,127,166,122,65,85,162,177,98,41,30,248,74,12,13,90,188,33,99,31,87,164,3,239,42,168,107,252,202,57,242,97,91,76,173,108,172,206,20,150,118,214,199,198,64,46,243,37,203,84,24,17,135,112,67,14,241,119,128,66,106,149,95,157,73,155,116,11,80,103,225,193,209,191,215,189,94,176,61,19,62,78,50,255,216,71,146,124,82,175,210,83,53,26,170,181,254,22,184],"expectedCoordinator":192},{"name":"generated-152-size-2","seedInt64":"-602398437191585362","attemptNumber":3233414658,"members":[165,158],"expectedCoordinator":165},{"name":"generated-153-size-27","seedInt64":"-6883469786163592992","attemptNumber":7,"members":[116,8,121,197,125,107,53,102,109,118,48,171,78,156,127,98,62,72,17,136,90,155,193,100,97,22,198],"expectedCoordinator":109},{"name":"generated-154-size-95","seedInt64":"-6340230278701012813","attemptNumber":35881,"members":[207,30,73,70,80,16,139,195,102,94,183,252,69,125,137,104,249,236,232,229,187,13,154,203,244,91,170,34,242,43,174,196,82,152,245,81,168,109,138,32,188,71,191,103,189,136,173,72,112,122,126,141,4,48,47,164,28,180,96,92,5,108,64,151,210,44,156,37,186,206,225,110,18,254,2,89,85,119,199,211,228,114,132,145,163,162,178,15,24,239,66,171,158,175,253],"expectedCoordinator":13},{"name":"generated-155-size-177","seedInt64":"9180814183342714081","attemptNumber":3492885651,"members":[85,92,96,90,174,74,163,254,157,53,95,192,191,201,6,239,111,60,229,68,61,22,150,153,219,52,66,69,147,42,248,57,125,119,209,110,47,165,141,26,149,88,8,223,207,86,224,70,124,200,120,234,122,102,121,216,25,215,50,24,218,123,135,236,55,16,142,211,128,89,244,190,7,59,56,34,15,173,183,205,175,251,225,65,255,253,166,181,193,31,235,197,245,180,233,45,131,247,220,129,23,5,217,27,113,127,79,168,252,21,115,126,232,29,182,75,10,13,167,3,228,49,161,176,43,162,143,154,187,202,105,101,118,152,41,246,14,30,138,148,73,51,107,76,2,4,226,109,37,28,99,199,91,81,189,208,194,93,137,132,54,140,1,186,48,249,94,130,195,221,97,231,83,185,160,100,210],"expectedCoordinator":79},{"name":"generated-156-size-5","seedInt64":"-298163913933788100","attemptNumber":2,"members":[157,61,241,162,126],"expectedCoordinator":157},{"name":"generated-157-size-21","seedInt64":"2056457369471652497","attemptNumber":27731,"members":[170,143,98,134,144,121,78,44,47,77,193,154,183,67,235,101,88,220,252,213,233],"expectedCoordinator":143},{"name":"generated-158-size-55","seedInt64":"6552520946031498069","attemptNumber":225110310,"members":[105,172,64,152,175,212,55,88,204,228,80,51,7,108,144,174,177,32,26,234,103,255,115,128,164,161,15,2,192,3,124,20,181,138,93,76,232,77,141,22,127,9,236,86,221,132,117,130,140,179,13,21,205,29,12],"expectedCoordinator":172},{"name":"generated-159-size-208","seedInt64":"-1472061768355741956","attemptNumber":3,"members":[88,109,103,213,204,162,36,210,116,112,90,44,205,89,144,81,226,234,239,60,117,147,85,191,105,57,232,208,211,11,138,189,55,86,33,28,78,5,58,130,70,176,123,252,73,99,110,25,48,66,139,188,197,94,22,74,246,120,167,194,253,238,184,141,93,182,140,212,125,83,225,31,45,169,49,16,76,187,248,98,203,221,160,161,209,174,37,175,240,115,61,222,202,97,54,152,131,143,71,122,41,18,35,12,108,214,23,50,163,77,228,251,198,142,255,95,19,235,247,17,39,224,64,242,119,72,106,241,216,151,38,121,177,193,126,244,40,7,14,146,192,207,56,157,87,185,186,164,172,26,82,218,127,168,199,75,243,173,133,155,4,46,206,111,69,15,104,237,30,79,100,114,190,84,118,107,29,47,63,128,92,3,178,68,236,2,124,233,230,245,215,156,51,231,249,42,137,180,53,32,113,62,170,149,181,80,67,158],"expectedCoordinator":140},{"name":"generated-160-size-1","seedInt64":"681655156400984660","attemptNumber":13218,"members":[127],"expectedCoordinator":127},{"name":"generated-161-size-22","seedInt64":"-5822253159313495463","attemptNumber":2225385698,"members":[154,87,67,143,17,248,34,52,149,93,43,122,182,160,137,121,85,63,78,159,49,230],"expectedCoordinator":122},{"name":"generated-162-size-67","seedInt64":"3310535431795534626","attemptNumber":0,"members":[159,126,29,44,163,24,39,34,105,186,228,10,70,161,150,235,51,204,11,224,47,7,73,178,160,207,226,46,28,239,201,95,120,168,157,223,58,194,192,247,211,57,166,144,78,195,215,100,137,119,220,23,53,62,151,222,181,49,147,42,149,35,140,66,229,169,219],"expectedCoordinator":73},{"name":"generated-163-size-123","seedInt64":"2743252143534095320","attemptNumber":22126,"members":[9,96,34,146,43,172,37,51,95,57,142,36,114,19,190,56,27,52,241,129,47,196,236,213,147,221,104,88,29,15,187,30,94,209,181,107,197,244,110,194,198,155,189,230,71,212,226,228,177,127,125,120,92,225,150,4,49,250,32,130,231,149,217,161,206,246,48,109,38,81,175,106,159,122,100,6,160,132,45,59,202,91,23,208,154,151,63,232,233,222,251,20,252,141,243,170,140,124,166,164,240,55,214,178,12,253,28,138,70,13,210,97,33,227,135,237,25,35,162,11,75,40,211],"expectedCoordinator":124},{"name":"generated-164-size-4","seedInt64":"-6913987789750835670","attemptNumber":4004014375,"members":[32,61,206,131],"expectedCoordinator":61},{"name":"generated-165-size-37","seedInt64":"4704084464270051707","attemptNumber":2,"members":[180,54,234,90,107,154,63,133,145,134,212,57,46,39,73,80,94,192,243,235,64,240,237,170,76,182,91,123,18,82,67,87,32,167,226,1,172],"expectedCoordinator":243},{"name":"generated-166-size-82","seedInt64":"7493457394091395981","attemptNumber":52541,"members":[167,183,36,206,221,236,247,223,131,37,142,152,146,159,213,25,168,101,92,203,243,59,217,88,125,83,198,116,165,33,197,51,143,196,199,173,154,228,12,28,115,151,30,8,231,60,240,218,110,29,144,46,164,73,78,86,16,193,161,85,136,108,38,80,63,128,27,9,24,11,232,99,82,244,190,57,56,68,104,74,226,180],"expectedCoordinator":85},{"name":"generated-167-size-144","seedInt64":"-356356534523922542","attemptNumber":3417387800,"members":[11,244,147,78,226,215,27,203,64,89,65,133,213,158,207,47,12,83,84,206,197,225,249,212,151,243,195,156,170,123,85,172,155,113,198,59,45,68,205,164,30,41,7,189,236,20,196,49,18,22,224,234,101,176,163,46,179,141,51,235,148,2,160,254,221,72,204,192,146,181,16,107,124,104,230,80,154,255,169,122,69,233,102,173,9,93,13,120,177,129,82,23,103,191,199,208,222,106,245,42,227,178,201,214,5,40,251,138,26,248,202,81,57,238,165,127,162,166,28,216,157,150,232,37,183,60,132,217,194,39,86,99,188,61,175,112,218,98,200,130,8,219,228,242],"expectedCoordinator":234},{"name":"generated-168-size-4","seedInt64":"-289654501694959618","attemptNumber":6,"members":[237,200,242,156],"expectedCoordinator":242},{"name":"generated-169-size-14","seedInt64":"3462692091532748907","attemptNumber":4601,"members":[218,62,173,138,5,191,23,199,20,47,221,205,38,154],"expectedCoordinator":205},{"name":"generated-170-size-70","seedInt64":"4146950138448183747","attemptNumber":817442080,"members":[101,54,98,42,119,198,44,34,183,205,90,202,81,12,112,243,128,222,230,80,184,17,216,251,97,209,192,145,93,233,122,190,221,161,228,39,162,144,19,130,176,242,52,117,174,7,133,210,208,170,41,159,22,219,254,102,156,253,215,38,67,73,154,236,59,24,141,57,108,137],"expectedCoordinator":190},{"name":"generated-171-size-222","seedInt64":"-1632780058821013484","attemptNumber":2,"members":[207,7,242,39,212,124,38,37,227,142,101,126,234,148,253,173,136,198,117,158,51,93,34,209,161,180,141,125,210,55,171,14,144,252,58,45,15,135,146,165,213,151,237,21,107,25,98,87,76,120,143,147,83,43,188,85,92,170,74,167,208,128,119,172,175,236,100,32,118,216,154,77,1,26,241,230,232,19,192,60,72,89,224,94,134,233,228,9,130,247,62,184,49,157,152,186,246,112,91,90,235,57,64,244,155,103,5,205,204,139,191,18,250,217,10,177,40,81,56,137,174,17,105,196,181,166,218,156,99,245,109,28,13,122,4,225,201,115,176,59,65,88,44,106,150,197,80,159,33,221,66,178,162,23,84,16,223,104,12,193,226,30,121,50,202,220,95,35,153,108,111,52,2,42,110,163,46,53,36,222,249,82,29,54,97,8,96,145,67,248,27,160,63,255,200,123,183,114,199,187,219,113,86,238,240,149,168,190,75,132,194,24,251,71,47,215,127,116,31,140,254,69],"expectedCoordinator":89},{"name":"generated-172-size-2","seedInt64":"5638754981721726777","attemptNumber":51454,"members":[35,222],"expectedCoordinator":35},{"name":"generated-173-size-28","seedInt64":"-4974233506603298602","attemptNumber":1531003781,"members":[152,117,128,186,203,244,217,248,84,222,95,38,126,23,50,104,56,229,71,150,18,162,220,118,99,121,138,10],"expectedCoordinator":84},{"name":"generated-174-size-62","seedInt64":"-1320490978783509885","attemptNumber":7,"members":[33,231,222,40,199,28,12,194,232,203,46,41,58,255,27,229,126,125,81,2,163,204,15,61,228,173,251,107,162,200,14,4,136,154,24,54,182,165,172,217,152,135,129,3,226,22,25,188,161,122,115,242,121,224,184,215,252,175,119,247,43,211],"expectedCoordinator":126},{"name":"generated-175-size-208","seedInt64":"-118113626857321878","attemptNumber":63168,"members":[63,198,2,35,4,249,166,88,34,41,173,202,224,196,31,205,226,27,98,75,236,28,128,122,174,54,85,223,188,38,237,82,96,20,94,181,68,60,104,127,245,5,144,239,183,219,207,199,44,74,184,158,241,193,117,55,59,208,149,229,214,79,238,80,56,195,26,253,176,123,92,106,100,16,46,1,133,52,40,248,209,185,101,57,152,66,231,49,242,194,246,155,121,29,235,72,131,240,23,212,225,201,83,187,145,19,151,217,107,87,7,53,97,161,190,103,17,37,143,130,159,140,150,105,168,12,179,178,250,228,247,148,65,77,170,113,43,13,15,50,78,108,99,120,138,134,252,70,32,162,135,3,33,160,137,132,251,124,218,118,156,22,192,206,42,220,215,86,73,186,233,84,167,58,62,116,157,71,95,81,111,222,180,24,230,243,36,119,154,165,6,164,244,109,25,163,169,8,182,18,171,125,76,30,142,203,197,175],"expectedCoordinator":122},{"name":"generated-176-size-3","seedInt64":"993162481201941121","attemptNumber":464118810,"members":[108,44,93],"expectedCoordinator":108},{"name":"generated-177-size-6","seedInt64":"-6826145883722901905","attemptNumber":0,"members":[110,229,51,211,1,38],"expectedCoordinator":38},{"name":"generated-178-size-51","seedInt64":"-1737530234147444458","attemptNumber":16429,"members":[75,11,195,43,76,170,80,69,52,183,231,83,145,114,214,138,227,189,57,237,225,101,165,31,223,200,244,46,211,246,109,247,160,15,132,42,176,110,123,19,131,228,2,196,124,10,51,147,134,175,26],"expectedCoordinator":227},{"name":"generated-179-size-248","seedInt64":"-8976107252718925492","attemptNumber":3055357771,"members":[117,111,145,222,199,184,109,151,147,141,210,164,113,223,144,134,209,28,228,69,119,97,62,52,106,247,249,59,105,232,123,27,153,38,110,30,61,50,218,44,60,195,20,26,238,154,99,31,137,135,67,239,89,86,216,83,186,104,63,131,130,107,124,16,25,114,220,227,193,133,162,36,163,129,1,211,206,180,14,196,157,138,51,166,204,54,252,46,217,132,140,47,161,64,48,19,187,126,42,43,244,100,73,37,229,240,245,231,225,148,213,84,10,178,198,253,139,172,96,90,150,158,95,156,81,183,33,248,190,55,88,101,103,35,250,41,2,201,169,6,56,170,78,7,177,112,146,224,68,234,11,243,155,92,93,85,203,71,212,251,200,3,173,75,122,94,167,254,80,98,202,23,136,57,171,65,125,152,9,191,91,4,237,12,214,221,197,17,181,175,45,149,40,255,179,185,5,18,127,34,24,165,174,208,242,32,168,21,207,205,108,121,76,116,192,241,236,77,66,226,246,118,176,160,79,182,194,120,82,233,22,58,39,188,70,8,13,87,49,29,53,128,74,159,143,142,15,72],"expectedCoordinator":126},{"name":"generated-180-size-1","seedInt64":"-8921232748599113321","attemptNumber":2,"members":[42],"expectedCoordinator":42},{"name":"generated-181-size-16","seedInt64":"-8162888834982691457","attemptNumber":30924,"members":[130,88,186,223,229,169,45,100,13,1,166,80,213,114,205,187],"expectedCoordinator":169},{"name":"generated-182-size-54","seedInt64":"6633691383179599504","attemptNumber":1276020348,"members":[136,59,41,97,195,182,44,142,51,49,114,225,231,167,181,163,83,46,228,253,242,3,161,9,71,102,64,205,13,10,224,192,112,197,133,180,1,82,74,117,111,65,250,29,154,7,208,30,52,222,24,98,62,5],"expectedCoordinator":41},{"name":"generated-183-size-166","seedInt64":"-2262985641463040413","attemptNumber":7,"members":[154,50,146,52,16,225,68,25,126,82,47,75,53,4,204,20,123,187,184,235,169,144,234,131,94,218,85,244,73,158,170,200,241,69,99,201,7,59,103,46,14,89,237,125,173,8,51,130,206,49,95,214,133,117,202,19,216,253,228,34,221,79,210,166,90,193,108,124,134,84,129,22,116,165,163,71,246,62,28,203,118,222,61,29,3,185,13,132,243,23,248,63,213,190,74,157,128,109,60,140,179,41,198,122,174,249,155,27,78,254,223,12,251,141,33,121,227,127,181,35,143,250,15,40,83,196,104,182,115,229,178,162,119,106,6,10,137,147,212,194,58,92,56,48,98,245,230,215,101,217,220,39,18,113,55,172,159,153,102,91,189,207,70,36,191,72],"expectedCoordinator":108},{"name":"generated-184-size-3","seedInt64":"975428083526443235","attemptNumber":55207,"members":[126,16,247],"expectedCoordinator":126},{"name":"generated-185-size-28","seedInt64":"-8228073379596709837","attemptNumber":214293528,"members":[100,198,8,79,180,13,31,18,171,75,76,81,255,232,220,130,178,142,239,122,99,182,107,52,250,86,47,209],"expectedCoordinator":232},{"name":"generated-186-size-74","seedInt64":"3209321565861461592","attemptNumber":7,"members":[137,226,211,11,129,250,152,140,159,7,153,22,237,149,51,145,175,216,148,166,170,185,12,238,156,68,16,124,50,67,119,201,183,107,110,178,65,163,97,77,184,80,242,34,203,23,106,136,200,169,224,173,25,139,72,99,81,135,59,193,104,108,35,28,96,74,252,82,122,126,144,89,125,171],"expectedCoordinator":149},{"name":"generated-187-size-158","seedInt64":"1147271317910824068","attemptNumber":15318,"members":[226,143,238,116,134,108,185,113,216,16,57,202,255,59,70,229,88,189,28,140,220,204,128,198,171,36,177,17,12,159,62,48,96,123,124,19,147,138,219,46,230,25,158,213,74,211,170,117,14,146,104,197,218,49,190,244,232,136,191,217,174,165,91,44,21,47,111,45,80,121,139,13,187,18,3,51,196,65,72,236,242,76,179,39,154,79,126,246,125,193,239,87,11,30,8,152,167,160,253,172,83,90,200,225,175,42,43,110,233,26,114,61,93,109,29,40,227,10,141,135,119,20,178,100,82,148,206,155,78,201,60,50,192,183,194,58,223,132,203,77,252,173,137,207,6,35,4,41,32,66,180,133,115,144,131,181,157,251],"expectedCoordinator":46},{"name":"generated-188-size-3","seedInt64":"8626384896298636603","attemptNumber":1945142325,"members":[252,249,32],"expectedCoordinator":249},{"name":"generated-189-size-27","seedInt64":"-2884192324852082523","attemptNumber":5,"members":[8,137,146,188,179,228,1,249,46,175,34,216,36,81,248,43,108,143,174,70,83,199,77,244,215,190,53],"expectedCoordinator":143},{"name":"generated-190-size-60","seedInt64":"1295608195474163702","attemptNumber":33305,"members":[17,94,158,90,237,123,57,129,60,130,126,54,113,181,2,168,92,47,45,218,174,176,23,82,21,219,136,106,97,238,56,178,13,99,190,157,227,125,121,169,188,10,39,26,216,29,148,230,205,207,63,156,213,221,112,254,179,163,241,16],"expectedCoordinator":10},{"name":"generated-191-size-102","seedInt64":"-7028840328211646751","attemptNumber":4205783693,"members":[213,8,200,85,94,57,147,135,145,150,22,65,211,251,225,255,3,140,163,54,238,81,242,151,159,231,253,134,51,166,195,254,36,201,227,214,17,178,79,104,95,245,205,19,148,235,45,78,106,16,34,127,202,126,59,174,33,187,105,75,236,89,244,53,6,93,132,189,157,208,7,121,103,172,117,194,46,13,247,243,82,192,161,86,91,160,252,77,158,5,191,100,218,39,2,27,120,30,167,111,169,102],"expectedCoordinator":205},{"name":"generated-192-size-3","seedInt64":"543025063281693162","attemptNumber":6,"members":[132,105,233],"expectedCoordinator":132},{"name":"generated-193-size-32","seedInt64":"8878370375918224043","attemptNumber":23628,"members":[232,196,72,44,84,102,224,88,49,138,127,175,65,166,81,21,33,167,141,225,97,108,73,139,152,136,105,178,160,18,158,51],"expectedCoordinator":108},{"name":"generated-194-size-100","seedInt64":"4927575092754923835","attemptNumber":1252619687,"members":[16,87,137,77,144,165,33,190,129,160,176,89,31,180,233,206,123,120,83,142,181,35,130,100,128,245,92,198,50,238,57,154,184,74,197,112,247,118,59,172,64,200,23,97,68,121,219,111,226,28,104,251,93,155,63,171,65,151,136,26,211,86,146,69,167,22,43,96,208,232,54,5,239,102,242,131,199,18,85,209,91,145,203,76,173,243,3,105,37,215,248,17,122,148,166,95,114,113,38,48],"expectedCoordinator":211},{"name":"generated-195-size-143","seedInt64":"5715653900797331802","attemptNumber":6,"members":[171,56,10,174,124,36,67,241,31,49,116,123,147,19,136,59,22,1,148,166,27,62,244,219,91,177,199,55,34,163,57,193,138,103,89,63,16,66,20,6,133,7,246,42,159,225,182,92,226,4,75,29,130,242,236,203,221,212,95,175,239,155,162,5,33,28,122,157,197,78,238,153,23,14,15,254,32,50,11,110,41,168,65,222,158,51,178,109,74,104,160,253,129,220,247,121,52,233,216,68,154,119,40,60,140,176,142,146,21,164,192,13,17,79,70,234,26,48,240,161,150,120,114,111,170,115,201,108,61,206,101,224,85,128,106,208,223,209,99,47,172,100,249],"expectedCoordinator":219},{"name":"generated-196-size-3","seedInt64":"2588217503720539712","attemptNumber":52720,"members":[27,237,80],"expectedCoordinator":27},{"name":"generated-197-size-20","seedInt64":"-2614392867029198303","attemptNumber":1297361721,"members":[201,206,111,103,86,231,121,200,181,66,156,229,76,25,99,104,59,177,210,69],"expectedCoordinator":201},{"name":"generated-198-size-76","seedInt64":"-2131164544493598318","attemptNumber":7,"members":[161,220,18,245,254,120,15,104,157,145,140,64,226,58,150,180,231,55,200,248,225,190,130,11,230,214,115,195,234,38,53,235,155,237,198,40,12,41,49,67,133,101,88,47,26,111,229,10,123,103,80,194,116,74,151,52,197,171,218,96,114,78,93,106,34,253,206,142,158,183,20,181,189,205,44,102],"expectedCoordinator":231},{"name":"generated-199-size-123","seedInt64":"-8102576855027398969","attemptNumber":24238,"members":[254,157,124,105,122,70,125,219,144,121,76,211,116,96,170,50,221,74,214,126,57,142,218,222,212,190,3,11,146,67,81,235,134,192,55,255,237,234,66,177,236,155,109,63,248,199,195,215,53,85,202,64,90,4,176,178,103,75,139,162,86,226,152,87,249,71,41,91,120,239,16,132,33,173,9,99,22,250,141,242,45,46,5,10,194,206,23,32,102,84,31,30,97,227,13,186,111,175,19,201,184,168,127,29,140,52,56,145,12,209,37,34,244,251,69,210,232,115,185,174,183,83,161],"expectedCoordinator":115},{"name":"generated-200-size-1","seedInt64":"-5391680750099403716","attemptNumber":1367176450,"members":[155],"expectedCoordinator":155},{"name":"generated-201-size-12","seedInt64":"9116950518898689401","attemptNumber":7,"members":[181,9,102,234,15,206,201,104,237,31,213,137],"expectedCoordinator":201},{"name":"generated-202-size-77","seedInt64":"-7543824559515626642","attemptNumber":28250,"members":[39,113,17,212,111,171,29,106,120,249,18,232,83,37,6,178,15,46,125,71,12,34,223,149,117,195,30,110,179,208,219,14,69,139,121,245,78,116,238,103,211,53,79,207,20,197,230,59,166,247,216,88,49,137,221,222,191,175,148,186,104,85,70,133,147,215,183,24,41,164,68,99,109,209,52,63,233],"expectedCoordinator":116},{"name":"generated-203-size-172","seedInt64":"-899848874885150603","attemptNumber":2117475975,"members":[115,172,120,68,81,181,113,227,198,168,170,129,38,10,243,188,41,167,110,24,202,214,150,133,57,17,199,131,176,42,21,143,30,218,62,106,108,48,134,248,204,153,203,191,91,52,73,8,255,187,166,44,89,210,180,141,78,105,53,208,60,101,92,222,69,217,45,206,75,194,154,76,6,192,178,171,186,121,148,82,151,175,34,43,234,33,65,96,156,182,211,232,66,32,147,159,239,219,93,39,213,50,90,197,49,228,59,251,56,246,233,29,111,5,179,26,47,13,7,118,22,146,140,184,142,253,104,132,20,223,155,235,70,126,138,125,16,221,160,183,116,145,35,195,72,152,114,67,231,119,31,40,117,136,149,250,247,74,189,144,137,163,157,135,226,229,46,88,238,1,127,71],"expectedCoordinator":184},{"name":"generated-204-size-1","seedInt64":"-3965154114514443","attemptNumber":4,"members":[118],"expectedCoordinator":118},{"name":"generated-205-size-38","seedInt64":"-1149877508151121822","attemptNumber":2217,"members":[69,28,194,171,145,151,197,200,127,207,38,162,5,25,139,241,152,249,26,71,27,135,16,247,221,154,1,107,253,179,50,177,73,87,108,209,170,191],"expectedCoordinator":108},{"name":"generated-206-size-61","seedInt64":"4591983772327981692","attemptNumber":1881220302,"members":[110,26,42,193,239,186,208,222,182,47,57,190,169,205,85,83,238,146,220,138,76,160,188,231,155,40,75,28,198,82,171,118,253,191,132,109,227,143,21,230,180,131,254,64,10,214,245,78,32,86,149,255,94,247,178,31,99,36,234,218,4],"expectedCoordinator":247},{"name":"generated-207-size-142","seedInt64":"6410417397216878394","attemptNumber":1,"members":[83,166,107,32,90,73,96,174,69,145,130,211,112,221,117,126,53,125,25,159,142,62,245,173,118,38,108,111,93,20,21,75,140,114,172,95,54,67,177,255,244,184,48,60,179,207,50,5,15,209,51,176,149,40,84,196,29,101,253,68,204,143,248,120,216,150,192,87,229,42,116,243,30,141,63,94,106,227,137,127,66,88,26,80,214,6,102,187,34,100,8,210,17,103,203,198,11,180,175,46,240,249,59,122,146,3,86,19,228,181,28,232,57,9,44,37,47,230,161,194,136,200,33,247,158,191,162,113,225,98,78,206,186,36,183,10,154,89,56,237,220,76],"expectedCoordinator":184},{"name":"generated-208-size-4","seedInt64":"-8086997236217718750","attemptNumber":42960,"members":[76,137,7,67],"expectedCoordinator":7},{"name":"generated-209-size-22","seedInt64":"3929635371483476119","attemptNumber":3383479143,"members":[46,212,196,205,188,51,43,151,32,9,5,118,71,139,231,55,78,211,160,4,246,134],"expectedCoordinator":9},{"name":"generated-210-size-56","seedInt64":"-7641947915435895600","attemptNumber":2,"members":[59,130,241,185,115,171,245,178,250,65,4,19,212,149,213,92,109,220,83,145,217,167,195,222,136,175,121,34,192,14,79,247,154,13,249,226,94,215,53,224,216,29,210,42,164,132,7,133,173,155,93,111,230,242,203,41],"expectedCoordinator":4},{"name":"generated-211-size-102","seedInt64":"739576942796342027","attemptNumber":16644,"members":[60,229,203,252,244,43,122,139,41,183,86,35,95,9,6,61,77,81,92,120,114,63,226,154,32,3,151,24,201,221,17,136,159,83,217,34,74,250,48,170,169,189,232,242,36,87,110,184,214,223,31,130,239,206,188,121,72,148,248,125,196,88,220,128,225,54,219,18,245,80,162,135,23,91,187,123,39,247,20,240,200,243,177,143,76,172,26,152,71,30,191,57,55,15,224,205,75,227,97,11,166,101],"expectedCoordinator":121},{"name":"generated-212-size-1","seedInt64":"6149971385321529657","attemptNumber":570738057,"members":[84],"expectedCoordinator":84},{"name":"generated-213-size-33","seedInt64":"-3569057797224295675","attemptNumber":1,"members":[26,70,162,205,80,79,218,52,82,181,244,132,223,95,185,65,12,53,189,229,252,199,112,186,8,196,50,220,204,131,114,110,243],"expectedCoordinator":82},{"name":"generated-214-size-58","seedInt64":"-8009124812602160081","attemptNumber":9719,"members":[80,132,76,204,190,104,72,126,171,46,88,208,242,68,246,26,194,130,192,62,59,100,58,160,245,131,122,210,35,147,248,2,203,139,101,183,109,137,63,5,227,134,18,85,110,175,133,189,120,249,185,4,99,123,159,28,115,129],"expectedCoordinator":109},{"name":"generated-215-size-231","seedInt64":"-4883368307391505744","attemptNumber":611732897,"members":[51,72,36,19,147,16,76,38,86,149,135,112,162,248,253,130,192,9,104,132,31,37,103,12,207,46,184,165,22,33,217,85,27,43,139,90,163,87,120,84,254,189,108,182,136,179,122,215,190,29,115,202,89,41,239,74,241,255,70,211,8,69,80,110,59,21,194,159,82,1,88,11,134,28,129,205,203,77,137,5,242,151,127,105,102,61,14,140,96,251,131,56,208,183,58,219,39,197,23,174,81,200,152,54,153,168,181,250,53,66,175,252,146,240,249,109,100,114,128,172,222,180,169,95,40,67,230,119,75,93,138,173,62,233,223,30,246,191,143,167,17,157,49,106,13,201,231,199,237,18,188,154,2,26,117,7,44,214,225,73,247,92,166,78,216,111,126,160,161,99,123,24,244,228,141,50,94,158,71,6,156,170,206,186,125,164,221,47,107,34,48,118,210,204,224,97,218,133,32,148,155,235,65,234,57,3,52,150,226,113,116,144,177,171,45,220,213,15,101,236,64,227,145,195,4,63,124,187,245,243,176],"expectedCoordinator":143},{"name":"generated-216-size-3","seedInt64":"3127750597862717190","attemptNumber":0,"members":[76,54,138],"expectedCoordinator":54},{"name":"generated-217-size-17","seedInt64":"-7313622320999402745","attemptNumber":18383,"members":[86,136,83,158,77,140,47,240,231,176,71,227,123,78,211,15,224],"expectedCoordinator":86},{"name":"generated-218-size-97","seedInt64":"-7901460277287155755","attemptNumber":4204861836,"members":[104,18,142,210,34,45,46,55,107,205,35,16,73,5,223,84,112,186,41,170,222,86,92,40,17,3,195,59,194,150,67,90,241,141,232,139,135,109,163,52,119,158,87,23,122,208,152,215,26,225,182,6,121,191,79,68,44,247,162,47,218,31,33,29,164,196,192,204,176,131,140,183,36,228,197,138,213,250,50,249,236,85,32,10,56,184,61,161,103,168,233,209,143,153,220,19,181],"expectedCoordinator":220},{"name":"generated-219-size-250","seedInt64":"8748606308652553825","attemptNumber":4,"members":[185,165,28,133,173,40,195,128,6,15,13,161,190,46,42,206,124,37,227,111,219,20,89,125,149,249,108,144,8,21,201,35,71,169,209,16,146,224,112,86,147,172,106,39,123,189,87,65,228,229,218,79,122,239,221,50,217,139,142,101,107,193,34,168,23,117,241,238,83,81,245,204,191,113,177,131,167,119,187,47,136,135,225,18,44,202,105,199,54,26,208,148,61,174,29,196,115,51,110,75,114,231,247,181,180,70,1,97,59,212,137,48,129,102,80,255,157,240,109,236,214,99,56,11,19,118,27,100,198,213,32,216,90,31,242,162,152,192,67,91,234,5,140,244,85,235,176,226,205,194,158,145,175,141,52,24,160,210,88,127,178,186,103,143,179,154,223,4,182,138,41,126,77,230,166,252,17,10,254,82,134,14,171,43,63,45,120,232,183,248,170,188,153,68,215,246,7,116,96,98,243,92,36,84,156,49,66,30,33,164,12,163,197,72,78,60,94,25,184,250,207,237,251,73,104,132,159,211,130,3,57,233,53,253,121,64,69,38,58,203,222,95,200,151,55,74,220,62,76,9],"expectedCoordinator":26},{"name":"generated-220-size-2","seedInt64":"-5246193501503078313","attemptNumber":24237,"members":[46,144],"expectedCoordinator":144},{"name":"generated-221-size-6","seedInt64":"-4758205372089020226","attemptNumber":3481607519,"members":[249,254,155,105,102,49],"expectedCoordinator":254},{"name":"generated-222-size-100","seedInt64":"-3531465967748470332","attemptNumber":2,"members":[73,249,222,17,63,198,27,167,71,126,187,111,58,186,204,103,43,83,132,18,122,211,102,179,164,149,208,176,14,128,242,138,30,116,235,8,255,26,31,49,9,155,85,32,21,7,251,10,197,227,124,188,228,157,112,189,119,98,252,219,93,91,72,135,234,76,99,136,41,29,69,159,81,165,153,156,241,19,148,108,42,118,141,162,243,94,34,107,3,152,247,90,88,101,182,174,147,96,35,53],"expectedCoordinator":179},{"name":"generated-223-size-107","seedInt64":"-7553141419980881475","attemptNumber":18294,"members":[183,130,221,178,248,208,54,140,213,8,81,115,229,112,51,133,143,150,1,128,67,240,7,100,233,157,165,160,241,70,187,204,182,84,148,162,17,216,45,25,37,31,5,245,163,39,107,217,167,56,93,202,30,164,152,101,106,146,90,94,158,76,223,239,34,35,68,49,66,238,18,95,242,80,207,50,194,171,230,98,231,85,250,243,215,147,14,77,252,206,188,74,191,89,177,124,251,20,71,123,99,82,144,161,61,11,3],"expectedCoordinator":37},{"name":"generated-224-size-1","seedInt64":"-1256379817844886986","attemptNumber":1954377663,"members":[101],"expectedCoordinator":101},{"name":"generated-225-size-21","seedInt64":"-8595065637633132556","attemptNumber":5,"members":[246,54,102,221,107,82,38,169,219,120,142,117,252,80,139,57,125,162,36,232,255],"expectedCoordinator":54},{"name":"generated-226-size-52","seedInt64":"8777751530411258240","attemptNumber":38989,"members":[99,170,196,214,7,225,40,58,28,84,4,146,193,166,142,171,211,251,45,207,54,95,23,88,197,2,128,32,121,90,227,141,205,172,222,221,9,253,78,92,178,8,158,91,71,74,254,203,160,57,213,43],"expectedCoordinator":4},{"name":"generated-227-size-230","seedInt64":"-504433708010591143","attemptNumber":1554434193,"members":[14,152,191,64,28,79,9,91,217,245,74,136,148,241,158,47,99,184,85,50,213,163,156,94,143,15,93,227,104,124,247,214,205,23,172,18,145,151,204,19,251,10,177,167,33,43,112,250,187,13,68,236,115,6,131,56,221,149,42,88,201,240,196,154,120,179,96,67,183,178,147,168,218,95,150,166,216,27,40,89,165,45,198,11,86,78,144,239,238,39,211,103,71,121,51,76,65,75,244,174,237,53,7,210,146,70,248,180,209,126,38,212,122,129,189,52,137,164,128,44,49,87,32,116,107,228,199,83,229,24,169,69,246,16,252,21,159,77,157,30,175,102,195,207,141,222,73,100,225,25,46,81,57,62,208,203,125,114,232,63,242,108,80,170,48,66,249,197,193,254,118,215,153,161,58,234,140,72,139,84,8,31,155,200,181,219,92,176,230,97,101,82,182,224,12,106,185,111,206,1,255,186,235,59,171,243,188,142,5,127,54,29,109,220,113,4,233,60,231,173,117,20,130,3,132,202,37,223,253,41],"expectedCoordinator":171},{"name":"generated-228-size-1","seedInt64":"273832933421146336","attemptNumber":3,"members":[110],"expectedCoordinator":110},{"name":"generated-229-size-30","seedInt64":"7093212679147128367","attemptNumber":16507,"members":[38,65,171,190,32,244,20,199,130,101,213,200,56,139,183,193,243,245,145,46,252,231,29,112,203,188,99,5,41,153],"expectedCoordinator":153},{"name":"generated-230-size-98","seedInt64":"-6994348950673903000","attemptNumber":874710904,"members":[117,181,201,66,197,69,12,140,196,126,136,134,36,163,98,236,112,104,100,82,160,150,18,14,42,164,152,213,162,195,50,220,3,198,25,49,122,19,119,73,71,143,218,204,225,30,54,148,129,246,238,125,94,221,37,189,231,228,38,222,83,40,175,166,76,56,159,192,165,114,41,55,233,110,186,106,184,62,177,183,210,52,29,207,74,120,116,235,171,232,123,34,77,172,156,239,22,200],"expectedCoordinator":184},{"name":"generated-231-size-181","seedInt64":"-2390953809965168301","attemptNumber":7,"members":[140,170,85,222,232,213,154,64,51,31,40,30,20,243,114,34,120,179,252,169,108,188,206,46,10,195,175,173,97,126,151,142,56,202,81,187,230,203,44,165,47,21,145,186,171,172,134,212,137,104,177,189,153,146,135,22,90,183,218,129,226,4,63,70,76,228,17,208,72,249,185,106,235,110,224,98,23,246,57,199,234,119,77,205,8,217,75,58,105,192,174,5,111,74,60,201,19,231,159,37,65,220,109,178,143,194,117,225,123,27,101,99,136,71,164,227,240,39,82,61,250,45,91,54,33,12,229,87,79,118,156,233,62,86,115,32,89,36,121,102,100,9,48,191,236,83,28,15,245,190,147,24,113,93,160,7,139,88,127,181,152,141,130,42,161,73,107,50,78,182,254,133,239,255,66,103,1,180,116,92,214],"expectedCoordinator":239},{"name":"generated-232-size-2","seedInt64":"-4796086228382859032","attemptNumber":54289,"members":[160,115],"expectedCoordinator":160},{"name":"generated-233-size-27","seedInt64":"491858744630001380","attemptNumber":3643407001,"members":[22,79,117,103,242,220,38,10,252,73,1,28,221,145,131,136,51,105,210,8,194,99,183,80,90,212,168],"expectedCoordinator":183},{"name":"generated-234-size-71","seedInt64":"231013778177039729","attemptNumber":0,"members":[123,152,149,8,216,178,93,251,45,87,175,83,227,34,103,195,119,11,19,217,17,169,98,30,9,127,102,133,112,181,25,213,197,179,41,22,233,43,230,79,44,118,142,101,228,155,90,78,130,55,165,186,237,126,70,159,14,85,157,232,40,67,226,214,167,76,105,21,37,240,131],"expectedCoordinator":85},{"name":"generated-235-size-157","seedInt64":"-202916297728017719","attemptNumber":42918,"members":[121,163,136,34,131,135,204,191,244,149,69,12,110,210,180,73,148,227,14,232,28,200,254,147,177,29,55,157,33,50,102,68,19,186,236,214,22,216,151,242,160,95,75,13,152,138,41,189,76,219,117,32,175,45,61,182,150,205,203,130,230,8,16,143,234,40,91,167,165,212,127,185,38,77,70,164,118,26,88,197,57,208,36,99,86,66,104,84,2,120,82,237,89,248,133,162,228,245,96,246,188,173,141,1,43,64,144,223,201,207,6,241,215,37,206,226,79,20,154,80,58,233,108,231,179,53,198,255,56,209,94,87,59,21,113,124,46,170,119,85,243,15,93,218,109,60,103,24,239,111,100,195,42,146,128,199,62],"expectedCoordinator":111},{"name":"generated-236-size-2","seedInt64":"7034622083423148509","attemptNumber":1380728834,"members":[255,242],"expectedCoordinator":255},{"name":"generated-237-size-10","seedInt64":"8236413080765525256","attemptNumber":3,"members":[172,82,155,163,101,248,113,61,123,32],"expectedCoordinator":32},{"name":"generated-238-size-75","seedInt64":"-6134270894593845958","attemptNumber":4174,"members":[29,135,150,4,210,24,91,197,235,85,102,94,37,17,242,136,31,20,188,229,240,47,74,113,48,50,119,219,118,105,185,182,194,10,142,27,162,212,253,45,184,187,151,34,100,152,76,8,82,205,15,92,90,6,77,131,181,200,109,83,179,123,120,207,117,216,2,232,89,43,69,101,169,84,144],"expectedCoordinator":91},{"name":"generated-239-size-181","seedInt64":"4270392664048416871","attemptNumber":2729361208,"members":[147,51,156,229,45,141,209,228,221,219,249,80,125,129,202,238,252,101,21,206,255,187,108,36,114,195,78,105,31,116,130,184,164,135,1,168,81,111,37,212,87,137,180,76,117,143,234,68,145,98,39,34,133,235,22,197,54,49,66,150,227,42,213,23,193,84,162,29,100,154,48,56,176,8,231,65,194,90,25,82,94,236,188,182,163,232,97,32,142,75,60,134,106,7,64,52,3,41,132,72,146,181,214,17,230,151,47,69,192,35,11,217,33,118,92,140,205,201,124,57,13,99,74,165,16,110,159,179,218,149,102,95,10,190,174,254,136,153,196,226,28,250,38,198,104,175,178,88,157,113,77,96,119,62,223,70,59,224,109,63,12,123,86,73,203,200,53,215,85,50,166,71,103,24,138,46,2,89,239,44,220],"expectedCoordinator":64},{"name":"generated-240-size-1","seedInt64":"-6081062665632825552","attemptNumber":0,"members":[20],"expectedCoordinator":20},{"name":"generated-241-size-34","seedInt64":"6237684551520271382","attemptNumber":10821,"members":[207,75,193,254,146,2,58,223,216,44,248,93,247,90,199,159,234,91,56,169,162,218,245,177,209,249,112,111,67,195,128,232,97,33],"expectedCoordinator":245},{"name":"generated-242-size-100","seedInt64":"6852822680341952090","attemptNumber":4099209259,"members":[35,113,50,10,9,190,104,131,126,212,182,246,97,125,83,241,30,110,148,200,191,111,244,93,146,36,206,96,66,39,231,124,44,180,229,243,59,20,207,106,142,32,147,215,80,112,233,85,13,127,26,122,221,226,78,64,172,234,79,119,203,175,253,171,181,33,247,23,153,103,252,5,25,141,102,158,108,62,204,154,70,114,248,43,195,251,117,136,250,245,63,72,54,101,77,61,75,51,60,129],"expectedCoordinator":153},{"name":"generated-243-size-165","seedInt64":"1222296780750165622","attemptNumber":6,"members":[114,124,96,187,167,24,194,153,88,103,63,108,159,150,234,165,179,220,81,143,39,55,135,229,79,218,162,156,176,214,199,84,139,189,221,98,13,208,161,72,10,95,54,241,101,26,82,181,132,197,1,33,203,30,9,41,136,15,121,71,246,251,67,249,73,217,92,37,250,28,142,129,144,190,127,233,222,207,36,206,66,193,196,25,205,200,6,44,51,186,38,111,122,100,64,219,215,125,198,117,86,62,22,141,107,76,236,5,151,23,247,4,228,59,131,75,182,128,56,70,43,248,155,77,149,74,34,19,120,245,244,240,216,113,119,48,239,254,104,42,177,180,168,147,191,29,195,18,163,21,58,115,140,47,175,173,185,145,237,242,169,164,99,40,130],"expectedCoordinator":79},{"name":"generated-244-size-3","seedInt64":"6825655268162647627","attemptNumber":13938,"members":[239,140,38],"expectedCoordinator":140},{"name":"generated-245-size-23","seedInt64":"4714309448685408082","attemptNumber":767269866,"members":[230,204,43,157,238,139,31,234,239,163,11,45,126,64,96,127,33,58,154,148,160,165,135],"expectedCoordinator":154},{"name":"generated-246-size-71","seedInt64":"-748218304704662432","attemptNumber":6,"members":[178,2,133,84,196,14,72,222,245,252,40,68,123,165,45,213,179,135,116,32,197,230,251,60,95,241,44,194,217,118,20,35,131,151,56,175,163,215,29,205,201,203,173,103,186,79,152,39,192,227,147,144,74,174,162,200,229,43,237,100,125,89,9,202,12,19,233,195,181,94,248],"expectedCoordinator":131},{"name":"generated-247-size-254","seedInt64":"5965969807358470671","attemptNumber":48555,"members":[222,234,141,71,56,42,97,84,116,14,177,118,146,175,186,95,242,221,139,105,98,201,29,161,100,125,78,113,160,72,80,91,40,231,103,76,171,9,130,185,204,236,251,197,115,252,209,147,60,109,214,69,63,120,226,128,206,44,172,166,159,79,30,151,3,87,112,117,249,164,8,155,178,122,12,110,1,33,18,34,182,121,213,75,10,199,215,85,154,180,237,232,253,247,52,124,148,133,67,227,55,212,81,208,43,167,48,250,7,89,223,101,196,220,24,27,140,77,142,20,174,179,127,129,192,152,153,176,202,156,65,158,254,90,93,21,16,143,82,211,19,224,246,54,2,194,36,198,11,244,230,46,31,248,28,35,132,57,203,137,38,96,94,68,210,255,200,83,184,126,183,70,106,123,193,32,173,47,104,50,243,131,169,17,41,102,239,26,99,165,66,5,111,191,39,241,188,207,233,64,59,219,136,4,58,190,149,108,51,238,163,73,23,245,107,187,195,170,45,135,216,162,88,144,61,217,49,86,228,225,62,157,205,53,138,6,15,189,150,235,74,92,37,25,119,22,168,114,181,134,229,218,240,145],"expectedCoordinator":34},{"name":"generated-248-size-4","seedInt64":"-4943718522498609792","attemptNumber":3883215660,"members":[36,189,148,228],"expectedCoordinator":36},{"name":"generated-249-size-31","seedInt64":"8791949043565888271","attemptNumber":3,"members":[192,235,114,5,242,23,176,72,198,120,52,177,204,193,201,75,60,217,77,66,238,184,130,142,252,253,223,194,162,88,127],"expectedCoordinator":184},{"name":"generated-250-size-59","seedInt64":"7664190085675562762","attemptNumber":36001,"members":[42,231,160,163,76,110,177,211,208,171,11,130,233,118,180,52,210,61,133,65,60,46,97,107,84,40,8,94,226,132,206,166,56,54,245,31,253,119,212,73,157,195,108,93,99,50,198,23,220,20,190,22,203,29,178,183,70,140,167],"expectedCoordinator":61},{"name":"generated-251-size-187","seedInt64":"-8805967620924864177","attemptNumber":294827977,"members":[68,228,85,15,28,171,164,181,196,111,118,147,115,33,37,90,31,184,250,253,173,166,242,49,24,134,148,126,29,202,101,237,60,39,212,74,11,216,84,14,135,205,136,220,36,2,43,129,91,97,170,203,239,139,19,89,122,248,67,142,251,217,105,182,87,76,121,42,127,82,99,6,180,190,149,222,152,34,123,23,230,59,88,240,193,162,209,96,199,155,32,106,235,104,110,172,191,58,195,62,38,7,198,8,47,186,151,78,108,92,145,18,133,65,40,160,179,3,4,140,219,27,70,168,192,86,189,73,81,16,156,201,197,83,207,163,35,218,225,107,95,249,175,100,20,64,154,231,185,146,112,130,245,161,80,159,143,167,153,187,56,57,125,1,150,158,10,25,13,120,211,243,98,224,103,229,116,48,9,246,241,114,94,254,46,69,61],"expectedCoordinator":10},{"name":"generated-252-size-2","seedInt64":"2212339530805772484","attemptNumber":1,"members":[241,82],"expectedCoordinator":82},{"name":"generated-253-size-30","seedInt64":"7913033632405333059","attemptNumber":56226,"members":[247,17,202,72,143,231,191,170,165,88,158,96,240,22,177,29,130,129,176,67,59,138,104,223,50,169,221,74,205,84],"expectedCoordinator":221},{"name":"generated-254-size-82","seedInt64":"-4281287842980213524","attemptNumber":1565376498,"members":[22,213,83,145,164,163,212,226,32,205,26,126,157,231,131,12,57,238,232,224,49,106,142,30,255,20,87,44,28,182,228,209,160,103,119,219,217,171,88,54,229,135,69,237,179,97,191,94,225,45,99,75,199,172,168,4,123,112,96,3,239,122,16,72,133,101,62,79,81,118,236,178,184,210,143,190,124,154,138,1,198,6],"expectedCoordinator":224},{"name":"generated-255-size-156","seedInt64":"8220282613324234144","attemptNumber":6,"members":[189,77,16,165,216,148,115,142,78,205,17,87,239,218,104,98,63,209,51,202,152,185,79,170,116,57,223,56,151,143,74,254,232,30,1,229,225,210,5,80,69,37,207,168,100,29,175,181,128,213,247,72,12,97,127,113,53,6,188,89,245,52,200,221,147,111,71,179,106,92,219,250,83,88,120,163,156,227,214,94,73,217,158,231,183,60,22,58,166,21,171,118,135,211,122,10,124,31,224,233,242,91,246,27,162,252,2,176,138,173,155,197,145,208,49,212,150,117,149,129,39,141,43,103,240,59,109,96,24,68,132,131,154,110,169,228,186,204,45,206,15,14,38,75,82,182,55,7,198,48,64,194,121,19,164,180],"expectedCoordinator":252},{"name":"generated-256-size-3","seedInt64":"807015637862885264","attemptNumber":48830,"members":[145,147,124],"expectedCoordinator":145},{"name":"generated-257-size-34","seedInt64":"-3361867704914039858","attemptNumber":1061350313,"members":[144,158,1,51,98,112,233,101,59,77,2,49,249,41,149,99,119,96,191,22,194,21,254,240,196,242,130,177,19,34,70,78,192,16],"expectedCoordinator":112},{"name":"generated-258-size-60","seedInt64":"-3866289711898701202","attemptNumber":3,"members":[164,211,153,197,49,115,120,84,93,114,107,71,14,104,78,236,23,58,149,48,177,135,26,94,217,106,36,232,138,220,102,87,131,98,123,61,29,105,128,189,222,204,75,40,228,10,188,176,51,8,231,227,77,70,59,172,108,168,207,161],"expectedCoordinator":107},{"name":"generated-259-size-231","seedInt64":"-8524482668430366388","attemptNumber":12118,"members":[90,220,194,185,15,114,115,102,93,153,111,99,151,25,213,191,175,187,92,40,230,105,211,58,82,48,228,76,250,144,7,161,207,240,106,242,143,119,30,170,238,70,8,113,61,226,72,198,141,121,122,118,66,55,133,16,236,154,145,74,179,36,1,94,6,203,202,41,165,14,142,199,2,128,13,164,155,190,32,24,208,216,29,239,197,62,200,65,38,28,231,71,42,245,129,17,221,57,223,80,125,31,178,162,68,112,215,222,176,140,174,130,166,89,86,47,91,135,173,43,123,100,67,195,177,109,108,137,156,83,251,75,167,241,181,23,77,59,158,37,26,85,196,56,210,78,96,193,84,150,136,247,252,169,138,186,146,22,171,182,12,33,60,49,4,246,73,46,120,88,160,254,152,235,98,255,180,39,139,79,163,217,148,172,159,10,244,97,81,35,20,237,27,219,132,233,214,189,95,5,53,21,212,184,54,229,69,63,192,149,204,104,205,157,87,11,227,127,45,253,224,9,107,225,18,168,218,183,116,201,209],"expectedCoordinator":154},{"name":"generated-260-size-2","seedInt64":"2084582661572346352","attemptNumber":3618570068,"members":[50,255],"expectedCoordinator":50},{"name":"generated-261-size-23","seedInt64":"-7666449982017874002","attemptNumber":5,"members":[255,61,65,25,247,165,225,155,179,110,109,108,23,59,37,152,120,88,48,220,222,200,219],"expectedCoordinator":219},{"name":"generated-262-size-57","seedInt64":"7199986430354044602","attemptNumber":8752,"members":[126,99,19,57,104,160,44,27,130,169,2,114,117,60,164,215,166,159,211,70,241,235,21,240,227,128,30,171,185,217,224,178,138,42,146,12,77,85,71,101,218,55,67,143,131,96,3,210,87,246,154,76,198,244,31,75,234],"expectedCoordinator":224},{"name":"generated-263-size-223","seedInt64":"6320475293786642821","attemptNumber":1451797712,"members":[232,63,132,107,142,203,3,43,225,62,50,111,7,31,14,138,92,248,135,136,231,165,17,15,20,202,196,126,183,61,155,238,38,235,137,30,101,141,247,227,28,51,189,8,106,213,120,69,35,167,10,242,161,255,85,86,93,160,57,199,214,164,47,217,90,158,194,74,112,210,125,119,53,29,130,48,83,241,25,78,84,212,180,223,58,145,9,45,206,103,98,13,237,56,71,186,240,222,100,200,168,19,40,59,52,173,208,34,149,177,104,204,230,140,123,87,67,94,157,6,4,95,221,113,129,70,54,41,179,198,252,65,159,73,201,172,150,197,192,216,218,169,207,24,233,226,114,2,89,66,82,12,236,170,32,148,110,211,174,184,195,22,153,55,185,16,5,246,144,109,253,154,81,134,21,228,108,99,27,245,175,49,171,176,215,205,166,163,33,254,209,243,182,251,224,151,124,133,72,97,23,181,75,127,1,143,156,80,39,26,146,250,96,128,131,79,229,234,147,68,77,76,37],"expectedCoordinator":198},{"name":"generated-264-size-4","seedInt64":"-5674409287784918354","attemptNumber":0,"members":[157,170,217,48],"expectedCoordinator":48},{"name":"generated-265-size-26","seedInt64":"-7647140400065609575","attemptNumber":27543,"members":[159,64,235,158,130,76,152,4,101,37,13,216,36,6,230,47,215,193,118,226,199,141,184,210,187,195],"expectedCoordinator":215},{"name":"generated-266-size-94","seedInt64":"7245527749130508584","attemptNumber":3814224688,"members":[165,179,74,13,181,11,73,192,101,180,231,85,14,10,103,120,157,190,234,86,4,241,95,49,6,81,195,142,124,69,5,166,137,59,136,116,72,191,122,184,127,100,177,78,25,80,108,93,193,79,67,197,24,208,233,113,232,253,112,70,146,129,66,238,150,250,244,109,105,36,64,149,94,248,198,189,87,57,33,139,140,15,151,110,154,215,35,128,104,97,135,175,210,159],"expectedCoordinator":4},{"name":"generated-267-size-253","seedInt64":"3341259619691440291","attemptNumber":6,"members":[8,161,231,110,38,88,169,67,79,77,189,173,78,232,98,241,143,159,64,80,18,113,170,141,160,82,11,71,172,33,101,218,31,133,43,193,211,99,207,198,7,94,55,162,75,126,72,213,27,125,217,57,63,46,187,149,45,152,52,108,66,196,255,208,130,36,136,60,122,233,84,219,76,17,41,137,229,139,24,2,135,106,253,190,48,179,13,251,30,158,104,102,210,220,9,167,34,14,221,132,144,178,112,240,153,129,247,21,195,47,242,216,203,180,58,194,146,154,127,157,142,164,200,10,90,238,89,212,19,111,223,156,6,117,29,120,56,206,49,83,201,54,32,121,227,248,65,103,165,246,177,197,37,150,151,22,69,70,226,109,181,184,26,91,155,188,5,140,186,85,145,16,250,182,192,230,20,28,183,148,235,244,131,185,237,243,81,128,15,222,62,53,40,228,23,115,95,204,249,124,25,97,168,245,138,39,92,202,105,234,209,239,225,59,254,116,35,252,175,191,96,214,44,171,147,93,42,114,119,86,61,3,107,163,205,118,1,68,87,134,12,215,51,224,199,73,123,100,74,50,236,166,176],"expectedCoordinator":85},{"name":"generated-268-size-3","seedInt64":"-5643143841159192762","attemptNumber":24331,"members":[211,150,178],"expectedCoordinator":178},{"name":"generated-269-size-12","seedInt64":"-715432384258311090","attemptNumber":2849882877,"members":[76,49,141,219,4,208,63,26,238,213,160,232],"expectedCoordinator":49},{"name":"generated-270-size-70","seedInt64":"7645027225218768417","attemptNumber":3,"members":[171,192,95,238,31,60,188,67,97,47,222,180,53,185,4,154,186,57,117,167,147,198,43,139,219,204,76,120,189,170,134,202,121,163,253,165,194,49,201,6,197,130,159,241,38,83,14,103,236,44,195,54,105,24,111,126,79,77,254,132,86,200,160,42,32,99,255,51,71,168],"expectedCoordinator":24},{"name":"generated-271-size-217","seedInt64":"5324154017756291778","attemptNumber":39169,"members":[66,140,25,72,13,205,245,105,135,138,154,177,21,127,57,94,228,99,7,145,64,91,166,80,234,180,125,35,209,184,39,201,141,230,114,206,5,134,9,165,248,36,112,74,240,88,73,139,4,27,189,81,225,183,149,196,78,34,1,28,222,202,122,188,131,241,251,104,47,8,132,63,152,10,226,204,108,40,129,29,17,110,150,142,211,153,30,89,128,244,37,50,246,219,198,193,164,117,249,192,176,55,77,123,199,214,147,58,26,255,133,93,238,220,217,221,252,59,52,200,96,185,68,178,67,62,242,174,33,109,100,111,194,161,167,148,215,231,70,16,38,49,179,186,146,32,42,76,250,233,247,75,69,158,103,90,83,2,162,170,102,243,31,156,126,24,175,44,239,106,15,107,12,20,98,187,60,229,144,14,95,137,87,237,101,11,54,218,235,84,160,143,120,92,61,212,23,197,6,3,46,56,119,172,86,71,82,173,224,236,136,22,116,124,157,223,43],"expectedCoordinator":70},{"name":"generated-272-size-2","seedInt64":"-8566485568532469585","attemptNumber":2639785519,"members":[45,87],"expectedCoordinator":45},{"name":"generated-273-size-42","seedInt64":"-1353653275728657925","attemptNumber":6,"members":[41,86,26,101,223,210,72,154,126,207,252,134,155,63,194,135,236,34,212,170,42,75,117,209,187,49,15,202,124,39,177,83,253,133,221,29,248,150,100,143,57,227],"expectedCoordinator":170},{"name":"generated-274-size-71","seedInt64":"-6820740860954741270","attemptNumber":4325,"members":[5,12,32,66,200,225,219,164,205,215,123,80,190,79,216,142,120,15,59,93,74,147,21,248,116,202,136,14,31,37,57,13,114,127,167,237,217,23,137,109,98,55,124,102,145,174,247,159,155,212,128,139,51,134,204,22,58,194,157,72,3,192,193,223,101,89,115,162,96,158,188],"expectedCoordinator":204},{"name":"generated-275-size-162","seedInt64":"4465252252970958867","attemptNumber":3641493425,"members":[209,36,214,21,15,243,151,230,180,133,17,185,1,7,227,213,221,193,244,188,161,91,126,16,240,139,132,157,235,86,112,75,163,137,28,217,30,178,88,63,170,119,46,45,99,66,190,111,74,60,171,43,113,248,174,189,3,129,147,238,54,33,177,59,229,32,122,37,159,205,31,110,44,65,150,200,255,62,175,187,196,239,56,89,48,10,69,192,120,165,107,26,108,218,29,40,184,191,4,13,162,253,71,121,123,8,105,149,52,72,90,203,233,20,228,97,160,237,103,23,5,106,117,220,250,245,102,202,222,176,61,70,104,116,68,78,57,39,6,11,2,58,101,53,173,118,27,42,81,201,204,77,125,83,9,168,247,156,195,142,47,84],"expectedCoordinator":43},{"name":"generated-276-size-3","seedInt64":"1534232423866477505","attemptNumber":7,"members":[16,135,122],"expectedCoordinator":135},{"name":"generated-277-size-9","seedInt64":"1519742999391803657","attemptNumber":23587,"members":[121,92,31,39,246,252,41,16,95],"expectedCoordinator":95},{"name":"generated-278-size-86","seedInt64":"-4248083624589171413","attemptNumber":1342592760,"members":[136,48,162,20,35,216,129,39,51,141,49,94,192,40,164,29,27,105,186,208,33,165,213,137,255,227,196,134,8,181,252,139,84,110,1,235,251,179,175,187,116,239,182,131,200,113,61,229,124,109,199,246,219,207,218,197,171,221,194,115,232,188,159,248,166,18,11,153,202,28,3,92,37,117,95,204,7,102,247,238,170,19,193,87,212,59],"expectedCoordinator":51},{"name":"generated-279-size-189","seedInt64":"-8344731829951945426","attemptNumber":5,"members":[2,129,120,197,90,213,248,221,207,21,127,118,147,20,178,163,102,255,189,47,196,249,10,106,162,16,153,75,208,131,240,46,130,151,88,66,64,19,105,85,135,214,170,28,53,15,157,73,192,3,154,58,109,152,35,33,225,166,68,216,94,233,45,185,218,89,230,4,188,117,113,167,134,22,87,98,6,148,72,229,160,124,217,155,202,119,164,226,69,142,144,139,222,49,186,201,107,234,50,99,211,43,199,195,96,251,116,48,18,93,133,126,212,236,63,237,146,235,42,26,224,149,169,122,25,227,145,11,38,171,67,198,34,239,205,220,209,246,190,241,92,61,231,252,187,79,138,97,60,174,219,115,9,80,57,24,125,168,86,191,84,31,182,91,7,78,245,175,59,232,40,39,228,244,82,161,36,74,123,128,141,76,13,41,200,250,150,110,184],"expectedCoordinator":127},{"name":"generated-280-size-2","seedInt64":"2033526897056424498","attemptNumber":64224,"members":[144,11],"expectedCoordinator":11},{"name":"generated-281-size-28","seedInt64":"8502842446050635631","attemptNumber":1828138660,"members":[228,192,11,39,231,226,70,74,127,121,117,153,240,158,78,65,119,216,56,107,196,102,34,143,255,81,47,182],"expectedCoordinator":226},{"name":"generated-282-size-83","seedInt64":"6488488242795270046","attemptNumber":5,"members":[137,156,102,57,168,157,202,136,233,59,165,143,154,245,190,65,248,4,139,43,101,34,11,51,178,247,99,116,126,131,170,217,164,221,231,219,71,255,84,35,19,175,238,241,3,124,87,29,40,36,37,1,215,198,93,26,45,138,151,115,69,30,117,246,239,253,150,147,205,162,172,94,250,167,197,163,232,194,149,108,237,208,185],"expectedCoordinator":108},{"name":"generated-283-size-208","seedInt64":"346586956335163999","attemptNumber":55487,"members":[131,1,226,122,100,35,103,207,248,203,107,40,220,64,99,197,128,32,177,219,81,20,191,199,124,102,184,123,51,252,228,156,233,223,85,169,214,97,127,109,189,231,164,90,238,62,69,167,229,19,104,162,158,221,116,212,37,173,53,208,12,65,172,152,119,227,5,92,11,45,41,26,24,155,237,213,121,36,193,255,57,138,52,160,66,80,101,98,34,141,58,117,72,118,126,132,142,157,29,125,28,2,75,161,94,246,25,120,136,170,68,15,114,182,111,113,249,192,215,21,236,254,163,105,250,133,190,149,91,96,78,59,244,181,165,23,234,71,247,245,79,30,67,194,201,147,110,89,60,16,151,74,115,224,43,217,218,176,18,54,178,134,188,210,202,200,171,168,216,145,73,205,232,225,82,148,27,70,33,135,106,4,174,241,14,9,137,50,185,204,63,13,183,61,93,187,95,84,140,38,251,31,240,195,83,8,48,186],"expectedCoordinator":127},{"name":"generated-284-size-1","seedInt64":"-849178398913758776","attemptNumber":1122602558,"members":[42],"expectedCoordinator":42},{"name":"generated-285-size-42","seedInt64":"-5252790474996594144","attemptNumber":2,"members":[173,12,45,230,28,95,151,29,208,150,59,138,246,227,188,14,20,68,13,222,245,81,200,5,39,131,242,90,87,147,244,57,44,10,3,117,54,231,198,205,88,238],"expectedCoordinator":39},{"name":"generated-286-size-87","seedInt64":"-1301748709969340648","attemptNumber":27282,"members":[53,244,248,57,80,219,38,246,153,1,234,181,11,88,77,170,201,193,4,71,166,72,112,139,115,154,83,229,10,42,5,96,24,16,179,171,141,235,254,142,30,28,45,138,43,31,65,209,132,199,2,78,174,243,224,128,86,108,168,245,70,75,125,47,58,127,8,159,200,175,210,23,231,220,92,196,211,161,103,85,129,7,95,60,66,250,120],"expectedCoordinator":86},{"name":"generated-287-size-212","seedInt64":"-6191604708764174130","attemptNumber":2612205887,"members":[161,190,93,226,25,150,148,46,109,16,106,212,198,118,136,59,47,32,243,222,246,37,28,8,12,120,130,129,201,85,55,143,180,160,2,22,10,58,239,82,111,61,113,234,185,189,100,154,172,131,233,125,202,195,103,231,184,181,221,101,107,91,7,98,142,123,199,35,137,13,193,227,140,144,76,149,146,30,63,75,114,51,157,139,248,210,45,71,50,79,14,124,205,204,96,62,238,44,119,41,250,206,207,169,70,64,173,241,252,24,57,127,164,15,42,29,38,135,255,170,3,194,245,97,56,242,49,81,86,20,9,67,18,95,54,203,69,249,166,19,244,165,1,112,196,104,163,134,65,102,251,94,6,108,176,39,168,155,36,31,4,183,159,99,158,53,178,73,128,77,228,23,105,152,11,187,5,40,232,133,132,236,220,122,219,167,162,151,80,48,33,153,145,213,192,229,90,224,217,83,223,174,78,216,254,121,230,92,21,52,88,66],"expectedCoordinator":42},{"name":"generated-288-size-5","seedInt64":"8460265521305944609","attemptNumber":0,"members":[120,104,212,55,198],"expectedCoordinator":120},{"name":"generated-289-size-45","seedInt64":"1334925036902726330","attemptNumber":44757,"members":[91,83,88,104,236,169,246,94,101,214,96,212,227,7,92,10,6,126,243,1,203,11,16,235,158,40,112,160,138,142,47,248,109,80,90,232,127,140,3,129,222,22,146,62,73],"expectedCoordinator":140},{"name":"generated-290-size-58","seedInt64":"7963897732361553443","attemptNumber":3379668956,"members":[4,73,113,13,144,16,7,26,156,35,22,61,147,234,14,30,209,117,218,74,247,141,190,158,8,106,60,204,29,50,176,64,230,215,48,116,199,225,112,188,194,107,136,139,3,76,233,254,154,145,102,130,11,42,245,111,21,57],"expectedCoordinator":60},{"name":"generated-291-size-183","seedInt64":"3743747289024107697","attemptNumber":4,"members":[120,6,236,168,158,244,166,152,248,198,148,86,34,117,186,222,122,232,139,108,16,208,20,150,254,121,199,131,233,220,39,26,223,194,189,83,212,195,71,191,176,190,181,165,193,174,96,123,5,147,237,115,51,41,46,4,235,98,73,14,243,250,206,179,30,192,72,91,197,128,228,90,153,160,31,238,35,210,203,69,133,113,178,89,55,36,217,229,252,241,196,105,100,12,10,253,99,118,149,114,87,77,60,80,227,211,70,58,247,135,40,67,38,234,126,29,132,159,188,151,22,200,219,175,205,183,19,103,33,184,215,145,17,107,109,92,93,119,161,216,44,110,11,164,18,221,66,239,204,7,82,79,49,173,146,101,97,167,75,65,185,62,224,37,61,2,162,141,209,111,54,201,25,42,231,154,95,3,142,52,138,48,53],"expectedCoordinator":61},{"name":"generated-292-size-5","seedInt64":"-6769660257035880930","attemptNumber":9711,"members":[133,10,56,3,186],"expectedCoordinator":133},{"name":"generated-293-size-36","seedInt64":"-3698874580135930524","attemptNumber":4233378993,"members":[247,226,29,207,86,151,47,122,23,230,27,160,127,24,61,214,63,178,59,33,155,202,115,141,211,223,91,195,5,117,236,39,21,176,180,185],"expectedCoordinator":47},{"name":"generated-294-size-88","seedInt64":"-4109064334437528177","attemptNumber":2,"members":[160,222,29,243,68,155,4,153,37,235,196,107,141,194,242,63,199,41,90,115,188,65,24,87,149,198,97,209,75,73,213,171,165,74,38,59,134,26,11,33,142,232,239,1,53,207,187,45,70,19,224,30,181,135,173,168,246,191,105,227,225,10,249,7,179,166,60,154,125,61,248,158,228,148,129,25,223,113,178,151,100,88,21,140,159,183,123,2],"expectedCoordinator":97},{"name":"generated-295-size-209","seedInt64":"6060606102012395678","attemptNumber":60587,"members":[77,81,108,237,100,207,39,61,168,23,230,105,227,243,122,40,89,197,206,199,151,233,5,16,229,119,220,134,171,236,83,31,30,54,126,20,234,24,160,6,161,10,115,103,2,210,208,135,204,58,240,224,144,59,241,149,170,245,244,33,8,11,214,18,212,99,4,142,111,97,78,205,201,123,9,95,91,22,55,1,202,238,113,130,147,68,66,3,70,82,128,219,29,116,80,56,94,42,74,153,41,154,158,194,184,143,76,159,114,117,166,193,180,131,182,246,226,146,249,221,165,63,129,87,19,152,136,92,26,192,121,13,176,28,86,191,90,62,223,188,181,231,53,49,7,196,169,15,109,36,255,213,174,65,248,209,162,178,228,60,85,120,133,163,27,52,127,172,200,35,225,21,32,71,118,164,232,88,96,106,186,72,104,148,93,51,167,218,137,79,14,183,252,50,198,45,179,145,215,150,175,75,141,12,132,102,187,251,195],"expectedCoordinator":214},{"name":"generated-296-size-2","seedInt64":"5765277026270687903","attemptNumber":1858507268,"members":[77,61],"expectedCoordinator":77},{"name":"generated-297-size-26","seedInt64":"4034657053873729993","attemptNumber":6,"members":[211,120,84,8,59,239,158,58,62,245,56,81,46,90,244,133,238,89,30,228,14,203,61,164,167,142],"expectedCoordinator":8},{"name":"generated-298-size-69","seedInt64":"7520671523198270765","attemptNumber":42658,"members":[173,219,104,199,46,245,237,94,131,17,32,185,22,30,151,93,133,246,26,115,71,111,208,9,254,96,63,155,12,154,213,100,157,196,175,18,6,83,92,105,176,139,126,135,43,27,207,73,23,169,113,2,134,56,242,160,114,51,191,117,90,109,243,77,164,62,80,189,36],"expectedCoordinator":134},{"name":"generated-299-size-154","seedInt64":"-1835676456601308670","attemptNumber":2386789891,"members":[230,121,107,180,19,112,83,105,108,48,226,12,129,57,219,143,34,65,50,110,215,123,223,64,148,227,32,66,185,43,8,248,174,186,183,189,140,199,11,191,147,15,60,104,13,119,41,152,134,79,27,239,73,158,241,76,225,150,113,49,162,86,229,52,161,151,224,101,197,106,87,181,92,58,251,179,133,95,160,242,172,167,206,252,70,100,155,214,145,157,156,96,44,132,124,85,200,28,175,128,35,163,9,247,20,116,137,173,117,166,46,196,10,94,187,122,4,208,125,63,26,149,5,246,236,103,74,159,221,135,29,210,17,78,16,18,211,255,88,75,22,3,81,99,115,245,47,178,89,220,235,198,114,203],"expectedCoordinator":85},{"name":"generated-300-size-4","seedInt64":"-4631034722845140786","attemptNumber":2,"members":[32,252,253,77],"expectedCoordinator":77},{"name":"generated-301-size-13","seedInt64":"4597630699952046607","attemptNumber":26797,"members":[152,74,239,156,215,115,114,36,106,209,121,6,109],"expectedCoordinator":6},{"name":"generated-302-size-66","seedInt64":"5654958411379756865","attemptNumber":205760467,"members":[103,212,20,230,211,104,70,252,25,163,164,174,224,63,31,225,130,153,43,127,217,41,183,161,21,238,160,243,77,156,189,66,169,237,4,82,155,236,86,181,157,57,154,151,23,122,107,75,162,172,213,95,206,219,51,102,144,204,129,170,138,244,215,22,180,106],"expectedCoordinator":41},{"name":"generated-303-size-161","seedInt64":"3640417374877905127","attemptNumber":2,"members":[32,75,93,6,211,111,33,17,94,176,221,175,103,80,185,57,28,63,87,50,206,67,38,41,68,123,51,253,78,72,96,203,60,55,88,250,117,71,218,208,158,132,234,213,73,201,237,140,26,54,189,148,113,65,116,195,151,49,130,205,154,194,241,220,91,4,172,39,207,169,164,192,252,14,126,239,173,254,120,31,52,133,141,92,183,81,95,20,198,124,108,118,82,36,112,1,24,48,209,12,85,35,150,134,236,235,217,197,191,46,163,162,84,69,121,246,227,19,10,23,187,180,15,97,204,30,240,244,230,125,29,229,99,53,219,242,199,59,181,3,115,216,228,66,105,223,156,102,243,167,138,42,232,149,147,129,89,160,76,21,139],"expectedCoordinator":33},{"name":"generated-304-size-1","seedInt64":"-533587930471524142","attemptNumber":34964,"members":[194],"expectedCoordinator":194},{"name":"generated-305-size-35","seedInt64":"-286205457714084574","attemptNumber":2874851459,"members":[216,81,189,38,229,116,100,27,30,24,186,211,232,165,124,235,166,83,108,156,251,163,42,79,44,138,143,97,121,66,53,102,206,71,201],"expectedCoordinator":79},{"name":"generated-306-size-65","seedInt64":"-7529918287902757072","attemptNumber":4,"members":[15,189,249,167,182,35,232,170,156,101,185,138,171,13,230,81,96,192,243,153,237,233,235,78,126,105,12,150,70,234,28,20,107,190,59,128,36,43,39,203,74,183,45,216,251,157,87,103,40,80,214,68,98,4,54,186,172,176,14,92,241,211,162,18,106],"expectedCoordinator":237},{"name":"generated-307-size-115","seedInt64":"1147489243025262916","attemptNumber":38889,"members":[254,136,4,12,82,137,25,148,43,123,96,1,108,199,117,185,8,48,222,223,186,52,146,197,216,206,250,156,252,28,19,29,160,158,81,6,75,87,59,165,215,198,134,143,32,21,168,144,88,24,203,221,33,20,44,129,177,235,3,74,38,30,119,72,17,225,97,106,212,255,209,22,107,175,193,31,37,253,200,227,58,246,92,50,95,178,167,150,34,84,207,18,23,172,191,135,247,112,237,111,51,110,130,121,79,80,9,114,180,242,226,147,232,210,170],"expectedCoordinator":255},{"name":"generated-308-size-1","seedInt64":"1154028153563691726","attemptNumber":1598436598,"members":[218],"expectedCoordinator":218},{"name":"generated-309-size-48","seedInt64":"8129216280207610659","attemptNumber":5,"members":[164,201,57,202,255,234,169,176,227,55,150,225,248,31,104,208,70,124,194,212,88,122,154,103,121,26,79,91,24,203,41,253,68,120,136,63,179,49,198,127,156,1,42,60,229,250,232,118],"expectedCoordinator":91},{"name":"generated-310-size-63","seedInt64":"-3675960736892375051","attemptNumber":10369,"members":[101,174,252,96,226,185,31,63,146,190,40,42,198,80,71,56,153,164,155,163,137,64,186,68,177,82,41,249,118,95,241,23,65,50,197,169,231,165,229,211,98,11,15,157,66,144,140,225,2,106,73,160,24,131,9,187,33,75,52,69,91,193,78],"expectedCoordinator":75},{"name":"generated-311-size-235","seedInt64":"4394581351719267346","attemptNumber":2749065642,"members":[3,200,100,156,220,185,203,244,140,186,194,169,106,78,44,249,5,150,165,207,136,218,240,87,113,28,253,46,161,33,196,34,176,22,215,245,177,31,209,10,184,202,4,88,67,211,20,174,12,236,198,49,14,55,61,104,183,149,65,111,201,94,7,81,32,82,92,74,229,26,91,168,255,6,118,210,227,50,173,181,208,224,62,127,162,41,89,252,204,146,188,248,58,45,36,27,76,246,192,29,23,8,130,63,190,153,226,154,193,39,90,129,70,64,143,17,172,235,199,166,212,2,171,9,195,132,237,108,175,223,139,79,231,52,73,40,155,167,145,101,213,243,1,230,121,42,232,35,96,98,59,247,86,115,148,122,221,24,142,219,123,103,71,241,48,53,75,205,13,170,135,21,119,57,206,60,56,151,217,102,251,147,197,11,97,15,233,191,160,158,164,250,84,117,157,138,163,112,110,214,66,116,54,187,80,239,179,159,43,126,152,68,47,133,180,137,19,107,134,85,124,30,83,234,238,141,228,69,254,128,178,109,93,114,95],"expectedCoordinator":4},{"name":"generated-312-size-4","seedInt64":"-1182911492953643829","attemptNumber":1,"members":[210,191,204,142],"expectedCoordinator":191},{"name":"generated-313-size-39","seedInt64":"-7927097093280803673","attemptNumber":39974,"members":[220,243,181,214,32,112,18,33,221,111,250,171,6,10,235,145,144,73,228,120,22,2,202,23,136,15,35,242,200,104,8,158,82,81,234,197,199,115,17],"expectedCoordinator":35},{"name":"generated-314-size-98","seedInt64":"8177957496488430895","attemptNumber":287707117,"members":[101,151,246,9,177,146,208,103,155,179,40,227,6,161,165,95,245,145,94,111,223,47,99,18,92,240,114,144,43,67,237,121,162,5,174,206,30,61,124,159,158,26,248,25,60,23,243,41,239,68,73,10,8,45,118,53,115,187,195,119,38,190,49,130,171,232,186,7,105,2,28,148,112,250,222,62,71,86,203,217,136,228,226,59,181,1,167,152,70,42,3,196,229,189,97,129,238,192],"expectedCoordinator":206},{"name":"generated-315-size-223","seedInt64":"-3318448994886996528","attemptNumber":1,"members":[161,218,176,180,121,137,3,97,154,150,73,94,223,10,28,168,59,27,11,142,41,53,102,77,170,76,24,193,23,4,224,206,26,55,175,179,238,131,215,188,209,66,124,108,5,200,109,254,136,185,49,1,178,119,52,222,207,194,104,247,69,64,226,227,62,173,166,50,13,71,151,7,67,14,93,134,72,141,234,183,253,205,91,159,61,246,177,33,51,140,241,114,196,129,212,220,181,239,155,38,112,84,117,231,63,19,132,88,79,9,250,252,235,123,65,89,190,248,197,43,192,232,152,6,171,92,82,31,182,195,130,153,216,213,158,100,133,169,245,44,164,225,163,118,54,174,255,40,122,244,110,2,242,113,45,42,202,125,105,156,22,35,85,30,243,17,165,219,162,96,229,237,201,75,98,78,25,138,16,240,160,12,15,120,116,167,204,86,221,249,103,184,48,18,186,233,214,148,139,210,211,32,143,46,39,101,20,127,81,236,251,21,57,145,56,146,90,228,189,187,60,29,191],"expectedCoordinator":202},{"name":"generated-316-size-5","seedInt64":"2794696337260855277","attemptNumber":61408,"members":[233,251,131,204,137],"expectedCoordinator":251},{"name":"generated-317-size-21","seedInt64":"-442699949838094251","attemptNumber":1225504949,"members":[5,152,240,81,221,164,173,72,4,122,248,214,189,6,20,182,66,3,28,207,108],"expectedCoordinator":221},{"name":"generated-318-size-72","seedInt64":"-2375301963124701923","attemptNumber":1,"members":[107,142,43,169,206,255,2,129,152,47,62,252,125,45,12,143,157,115,59,150,144,162,226,254,242,46,22,78,147,179,48,217,151,21,20,153,229,82,145,234,16,191,120,211,227,38,230,139,26,215,213,244,238,155,188,173,10,186,223,104,103,66,85,210,118,5,14,228,149,49,181,79],"expectedCoordinator":48},{"name":"generated-319-size-213","seedInt64":"7602520889459848657","attemptNumber":8276,"members":[250,5,16,120,138,231,69,170,221,246,251,89,64,158,66,166,245,63,106,252,143,116,186,60,31,205,244,37,227,52,198,196,219,96,146,132,29,19,214,140,181,25,2,18,133,145,49,88,46,206,100,207,113,169,51,82,6,192,155,223,197,151,165,122,17,134,177,144,85,73,248,83,80,220,150,38,179,237,56,28,204,203,139,128,92,24,215,10,90,65,199,33,185,12,131,7,180,15,176,97,191,208,239,125,23,61,225,156,45,218,195,228,226,48,241,59,95,213,148,200,13,240,235,135,21,163,91,50,35,108,175,126,1,58,71,72,124,118,20,232,224,102,94,84,117,41,202,43,47,173,154,101,254,152,127,211,189,130,86,187,247,27,110,39,193,53,111,76,238,174,236,44,114,164,172,142,230,93,190,107,119,54,160,212,137,57,11,103,182,121,98,8,159,9,78,171,253,104,75,222,149,161,217,147,184,210,112,26,188,81,4,216,136],"expectedCoordinator":161},{"name":"generated-320-size-5","seedInt64":"5829511963767178350","attemptNumber":2079359527,"members":[180,231,32,226,229],"expectedCoordinator":229},{"name":"generated-321-size-17","seedInt64":"-4037747378539566058","attemptNumber":7,"members":[132,108,4,91,228,158,14,68,55,197,23,82,255,190,32,89,163],"expectedCoordinator":255},{"name":"generated-322-size-73","seedInt64":"9151737678042439299","attemptNumber":64013,"members":[146,253,47,229,193,154,139,239,136,30,246,42,190,201,31,84,177,187,120,34,46,100,211,37,179,8,33,80,28,111,243,164,196,50,81,159,236,91,7,249,217,89,145,226,97,188,163,69,241,238,225,167,144,75,160,153,181,53,101,85,213,64,184,247,4,148,23,122,79,180,195,59,66],"expectedCoordinator":111},{"name":"generated-323-size-140","seedInt64":"7704055951887796161","attemptNumber":2741488147,"members":[69,225,151,204,63,144,180,155,240,166,17,203,121,32,89,53,107,199,37,146,84,48,133,243,132,224,7,160,223,58,170,116,148,64,65,101,216,213,61,104,222,19,248,208,226,130,6,202,113,43,154,3,254,26,188,186,111,52,245,156,201,198,92,31,211,206,209,165,80,18,185,13,183,190,242,122,20,187,218,115,126,192,99,66,233,221,252,2,182,41,174,10,255,114,167,24,38,25,159,145,175,172,142,128,103,157,249,86,162,135,94,12,106,46,120,215,164,178,195,143,214,109,98,23,118,158,139,40,217,149,27,177,253,16,77,219,5,150,244,131],"expectedCoordinator":224},{"name":"generated-324-size-3","seedInt64":"-3001124718866607244","attemptNumber":6,"members":[78,83,38],"expectedCoordinator":78},{"name":"generated-325-size-8","seedInt64":"44880964751921170","attemptNumber":40954,"members":[218,221,41,232,240,155,28,112],"expectedCoordinator":221},{"name":"generated-326-size-74","seedInt64":"3324970582664224964","attemptNumber":2991928361,"members":[99,17,52,9,104,42,118,218,220,175,96,11,105,132,34,167,53,77,228,129,114,158,92,133,85,223,83,241,38,134,135,121,62,177,163,188,64,122,149,28,182,48,102,89,6,237,198,36,45,206,66,57,243,12,16,93,174,204,55,51,44,207,172,106,226,22,183,202,68,63,209,130,165,154],"expectedCoordinator":104},{"name":"generated-327-size-212","seedInt64":"8282963999938701238","attemptNumber":7,"members":[170,42,124,125,65,105,122,13,87,81,106,102,165,205,110,149,132,115,57,141,113,216,238,68,90,235,38,227,215,61,210,114,197,70,251,116,211,232,231,176,155,244,139,111,174,84,127,92,224,55,20,221,48,4,17,39,133,158,30,250,83,147,218,5,74,130,178,255,6,69,22,187,219,120,123,52,207,15,131,96,240,75,142,121,24,76,103,195,186,151,45,51,18,181,249,25,3,11,108,64,145,27,28,88,208,37,31,230,77,14,254,126,164,233,246,184,237,58,229,93,8,29,118,252,89,222,59,239,173,82,191,241,198,99,72,223,50,245,242,53,185,86,32,36,23,156,152,80,62,19,67,94,104,129,16,135,46,162,98,12,10,128,157,79,107,73,169,136,34,26,148,183,63,226,2,200,213,101,179,95,167,7,234,194,202,153,143,253,236,180,206,228,119,91,192,168,220,248,209,100,190,54,189,33,163,243,201,193,171,41,56,196],"expectedCoordinator":132},{"name":"generated-328-size-2","seedInt64":"1664367199510311429","attemptNumber":54177,"members":[209,235],"expectedCoordinator":235},{"name":"generated-329-size-48","seedInt64":"4077385515459304567","attemptNumber":2966919393,"members":[237,197,36,98,109,182,242,217,40,162,223,210,120,130,105,79,184,115,55,126,2,65,195,43,173,102,44,187,249,174,238,192,245,51,75,196,64,111,26,119,34,85,159,246,13,213,122,177],"expectedCoordinator":115},{"name":"generated-330-size-79","seedInt64":"4067167176216684183","attemptNumber":2,"members":[206,34,35,86,122,199,22,139,222,255,234,123,166,137,103,33,56,142,169,64,101,89,42,186,43,165,63,99,200,170,173,223,2,67,91,192,236,153,216,176,66,155,141,21,7,129,140,163,243,93,15,237,13,149,87,3,174,152,69,175,17,180,32,240,229,213,127,20,135,226,72,228,195,9,88,79,92,144,77],"expectedCoordinator":2},{"name":"generated-331-size-195","seedInt64":"-3734345061815279098","attemptNumber":20358,"members":[9,89,65,171,176,130,113,181,97,57,12,54,221,201,144,53,126,112,143,61,156,160,150,211,235,239,111,90,80,207,4,40,81,186,175,91,210,128,47,192,74,88,165,233,220,240,104,247,138,63,159,213,18,226,122,139,70,223,169,217,136,131,154,120,208,62,206,95,66,23,14,69,151,58,152,15,212,1,108,145,185,142,2,109,25,114,6,43,214,72,46,168,10,11,92,147,118,157,167,202,162,121,229,24,3,79,190,110,22,64,245,163,87,203,7,219,243,191,231,83,51,222,59,205,158,31,204,140,44,209,182,166,255,101,137,27,29,76,133,36,75,224,32,132,37,71,215,135,78,33,100,193,8,196,48,164,249,246,67,106,41,35,178,252,84,198,170,96,98,197,125,189,56,55,200,199,124,20,248,187,85,119,129,179,153,39,5,134,86,103,188,73,172,194,30],"expectedCoordinator":178},{"name":"generated-332-size-5","seedInt64":"7180813223174769649","attemptNumber":3777703075,"members":[61,204,238,6,3],"expectedCoordinator":238},{"name":"generated-333-size-8","seedInt64":"-5987923548488851421","attemptNumber":0,"members":[196,99,244,10,182,70,133,250],"expectedCoordinator":182},{"name":"generated-334-size-52","seedInt64":"9139848762656414059","attemptNumber":22672,"members":[250,251,220,73,26,113,105,191,14,144,225,95,51,28,202,219,32,47,167,134,61,175,171,183,229,139,240,190,103,98,196,164,20,54,52,185,108,106,255,78,253,189,242,200,136,114,159,57,12,146,184,30],"expectedCoordinator":175},{"name":"generated-335-size-108","seedInt64":"6244580102525754611","attemptNumber":491295725,"members":[97,172,245,255,53,224,67,99,185,226,206,89,131,144,64,4,223,163,19,35,106,36,54,186,175,134,145,152,158,249,129,93,66,221,40,108,125,130,244,119,205,137,14,253,21,154,170,142,73,32,113,233,151,17,183,218,123,1,62,47,96,182,208,104,132,10,179,77,107,173,49,88,103,176,198,37,216,70,84,201,79,168,149,199,153,220,138,157,8,146,124,202,34,133,150,38,181,94,117,44,26,114,65,28,242,194,215,39],"expectedCoordinator":119},{"name":"generated-336-size-4","seedInt64":"-3129885752828963201","attemptNumber":1,"members":[139,8,75,41],"expectedCoordinator":75},{"name":"generated-337-size-12","seedInt64":"-1496785616394005518","attemptNumber":63273,"members":[91,82,250,12,18,73,150,118,244,193,181,143],"expectedCoordinator":181},{"name":"generated-338-size-60","seedInt64":"8051397413930387474","attemptNumber":4291044044,"members":[13,61,161,229,47,137,253,176,40,119,207,95,90,189,23,234,206,78,16,247,52,51,215,3,155,48,233,220,198,29,58,244,243,212,77,86,46,85,115,123,56,109,211,144,82,110,188,98,165,238,226,6,156,57,79,10,180,21,5,111],"expectedCoordinator":215},{"name":"generated-339-size-226","seedInt64":"1869831488665591720","attemptNumber":0,"members":[21,230,85,45,87,82,106,16,107,86,115,228,54,233,49,34,179,116,64,242,93,133,223,132,248,103,111,209,213,63,229,19,31,122,78,12,129,15,216,74,170,252,8,155,161,147,98,232,196,56,220,113,148,197,70,57,203,226,33,20,62,169,96,236,48,71,237,77,251,255,253,92,130,204,139,143,35,66,191,141,121,99,120,181,25,206,4,231,52,41,11,210,46,24,14,205,126,249,160,167,190,108,53,247,188,10,241,42,138,202,246,218,90,165,27,152,222,61,119,22,183,89,244,37,254,144,168,38,32,67,240,50,164,219,201,80,221,149,91,3,234,13,6,105,5,217,110,30,127,193,177,65,26,114,73,174,215,1,60,146,207,171,2,125,145,154,102,172,192,135,200,250,180,40,23,157,187,59,79,235,238,9,118,51,208,28,69,76,166,182,136,142,94,163,173,68,225,212,88,109,55,156,7,239,184,198,101,153,211,189,134,123,175,245,29,128,214,100,178,117,58,137,194,140,44,185],"expectedCoordinator":173},{"name":"generated-340-size-1","seedInt64":"-4447057605273643094","attemptNumber":28748,"members":[165],"expectedCoordinator":165},{"name":"generated-341-size-11","seedInt64":"704873975442180234","attemptNumber":669213940,"members":[101,94,250,191,200,100,180,153,6,213,212],"expectedCoordinator":6},{"name":"generated-342-size-85","seedInt64":"2137791194985341945","attemptNumber":7,"members":[84,167,249,94,181,119,90,162,248,208,239,14,17,33,216,146,203,26,159,108,253,132,67,147,52,31,177,12,195,186,212,151,224,137,59,34,194,210,11,226,222,110,225,204,29,85,245,144,95,5,27,246,30,109,241,125,199,23,242,189,230,64,46,15,152,135,130,66,200,16,60,56,2,91,45,171,218,18,118,138,21,116,13,213,105],"expectedCoordinator":13},{"name":"generated-343-size-110","seedInt64":"818697496696182558","attemptNumber":47134,"members":[2,84,161,147,119,11,16,174,206,21,176,202,102,54,178,1,104,190,40,146,51,214,103,46,236,19,254,70,141,169,28,239,166,74,26,25,188,235,135,150,72,226,118,52,66,9,38,210,250,71,173,216,49,248,163,201,230,196,243,217,88,237,197,27,101,191,123,53,85,224,116,78,122,112,94,184,117,213,31,108,8,86,149,140,47,165,44,87,179,187,64,41,95,186,181,107,157,76,164,109,144,205,148,200,180,83,111,89,137,106],"expectedCoordinator":72},{"name":"generated-344-size-4","seedInt64":"955598320770736980","attemptNumber":3247897681,"members":[91,38,61,79],"expectedCoordinator":38},{"name":"generated-345-size-14","seedInt64":"7204740131636628832","attemptNumber":2,"members":[32,43,78,30,75,67,218,148,212,183,48,235,54,181],"expectedCoordinator":212},{"name":"generated-346-size-62","seedInt64":"2887492199662702091","attemptNumber":56610,"members":[210,119,22,251,27,75,21,124,71,255,221,168,236,245,149,114,197,99,98,150,142,24,218,60,123,96,55,122,184,162,67,107,62,250,113,182,233,65,81,216,134,248,30,219,155,54,188,5,165,120,192,249,95,242,191,6,212,57,117,226,13,2],"expectedCoordinator":120},{"name":"generated-347-size-181","seedInt64":"3906008827320911801","attemptNumber":1738218105,"members":[28,166,73,196,93,133,132,87,25,178,30,14,78,141,34,213,168,184,198,11,60,183,71,74,2,119,187,144,223,44,102,154,126,221,232,9,194,214,64,19,241,89,127,108,55,17,90,248,116,185,173,193,80,230,122,227,15,91,82,208,250,151,69,182,181,254,100,16,204,147,83,135,129,96,10,43,118,210,245,81,143,72,48,46,110,112,97,8,45,138,113,5,157,160,136,41,242,247,140,35,215,209,188,211,51,169,4,38,39,234,197,155,217,123,105,63,7,176,131,224,177,88,165,146,18,3,190,23,236,22,128,52,171,252,164,121,251,159,192,149,202,47,12,92,216,114,115,243,84,174,228,156,240,233,255,170,124,148,20,58,152,67,134,244,99,79,13,249,212,226,253,201,76,238,239,186,103,57,98,231,145],"expectedCoordinator":51},{"name":"generated-348-size-5","seedInt64":"-1016237783980719671","attemptNumber":4,"members":[192,19,92,30,131],"expectedCoordinator":131},{"name":"generated-349-size-35","seedInt64":"8107214548653462612","attemptNumber":14987,"members":[81,25,56,236,88,176,252,50,141,84,169,70,13,137,101,189,224,164,177,206,112,168,214,219,11,79,181,111,131,185,108,172,39,218,249],"expectedCoordinator":137},{"name":"generated-350-size-90","seedInt64":"1613344499385377859","attemptNumber":591566116,"members":[64,73,52,120,136,211,174,135,91,147,246,144,190,227,134,19,94,194,50,141,179,186,212,234,180,32,205,36,193,59,47,232,209,238,253,218,197,225,112,142,11,78,243,23,173,203,54,89,133,221,118,38,146,131,164,13,150,122,233,154,251,177,65,195,9,226,252,123,25,41,110,102,207,220,53,57,6,101,188,42,153,178,199,111,62,76,39,172,245,196],"expectedCoordinator":57},{"name":"generated-351-size-198","seedInt64":"-1171288928880102437","attemptNumber":1,"members":[205,50,168,226,139,23,136,213,113,251,197,212,142,18,214,124,241,218,240,60,145,83,185,161,233,250,64,15,183,252,57,224,6,211,81,219,38,162,97,172,206,41,112,55,95,8,70,24,106,173,94,229,107,192,1,199,20,91,105,78,69,67,28,189,165,147,181,188,148,174,230,47,154,27,65,14,30,187,75,220,209,239,150,163,193,242,85,146,90,244,110,21,243,133,54,104,144,149,177,87,202,227,176,237,43,77,222,4,167,92,228,236,215,66,33,254,26,122,138,153,249,223,120,16,200,152,22,34,116,88,102,128,103,151,221,3,36,37,186,76,201,39,86,89,72,58,10,160,61,134,137,115,25,248,198,195,96,44,71,121,247,125,52,235,194,129,178,207,82,98,49,182,45,180,62,7,246,79,63,93,40,31,208,179,245,118,35,169,135,32,159,19,99,232,123,175,132,238],"expectedCoordinator":249},{"name":"generated-352-size-1","seedInt64":"8646868891905212189","attemptNumber":1835,"members":[65],"expectedCoordinator":65},{"name":"generated-353-size-41","seedInt64":"8044389130235457331","attemptNumber":2495527618,"members":[36,56,96,95,13,110,239,26,57,198,6,22,67,34,31,240,183,245,191,181,213,225,203,185,77,236,12,153,61,212,202,255,241,106,100,237,94,139,149,66,52],"expectedCoordinator":153},{"name":"generated-354-size-69","seedInt64":"-4981818601732735306","attemptNumber":7,"members":[22,176,32,131,218,73,120,47,196,159,141,98,94,99,241,25,101,197,121,55,9,136,243,112,107,35,147,113,103,81,172,140,102,255,16,204,17,56,26,70,42,207,79,191,224,91,119,177,51,27,72,149,125,23,18,187,62,166,165,4,139,238,221,89,213,173,97,122,144],"expectedCoordinator":243},{"name":"generated-355-size-167","seedInt64":"2537126541657727730","attemptNumber":7800,"members":[172,65,245,11,134,111,161,50,230,184,231,185,218,157,167,162,183,80,16,182,135,255,103,73,64,150,128,49,252,102,126,60,72,108,4,221,98,240,223,212,89,15,121,235,18,31,27,70,34,116,55,146,207,122,195,176,127,177,242,254,79,97,214,165,22,83,48,44,93,200,190,241,198,117,227,8,74,99,229,35,210,180,57,14,141,104,118,213,234,153,151,222,205,237,239,232,63,39,21,152,155,24,43,246,154,36,90,42,106,52,112,9,56,58,139,206,95,51,38,147,47,143,84,25,215,199,204,107,145,5,144,236,219,131,208,203,148,160,187,216,77,109,29,194,130,88,33,119,181,129,10,53,17,251,40,226,238,249,228,202,179,247,123,91,225,92,191],"expectedCoordinator":33},{"name":"generated-356-size-4","seedInt64":"4025575101090010839","attemptNumber":3185209672,"members":[137,19,204,67],"expectedCoordinator":19},{"name":"generated-357-size-27","seedInt64":"3680595689841125018","attemptNumber":0,"members":[95,166,147,90,1,57,214,184,55,242,197,52,247,39,159,140,25,34,222,71,84,103,125,168,251,213,130],"expectedCoordinator":251},{"name":"generated-358-size-83","seedInt64":"1023207033875735643","attemptNumber":8655,"members":[32,168,95,129,143,106,6,253,225,165,110,172,70,66,116,72,188,223,212,2,62,48,115,216,92,227,12,27,233,245,33,155,123,20,193,107,57,46,42,121,23,13,61,44,139,136,133,51,16,119,53,200,211,114,112,148,67,79,214,249,8,22,126,141,140,231,234,52,77,103,14,189,215,86,199,254,31,246,142,161,167,43,63],"expectedCoordinator":165},{"name":"generated-359-size-172","seedInt64":"4473865898076328431","attemptNumber":567286057,"members":[49,107,52,55,14,204,82,164,15,70,237,137,27,207,43,78,20,3,161,192,57,195,8,238,203,105,6,119,251,147,37,151,131,11,17,156,21,240,152,80,9,39,83,143,56,244,191,51,42,25,62,255,118,254,197,218,235,171,28,175,222,29,63,242,189,114,76,53,89,40,69,68,196,210,241,71,41,86,132,124,219,165,228,168,172,72,245,193,106,247,215,202,65,159,232,93,225,50,209,178,97,176,248,208,155,103,211,220,18,26,167,64,38,214,47,141,61,7,226,4,184,32,10,30,212,34,234,146,249,94,98,253,182,252,31,185,144,77,22,239,233,112,179,12,188,101,100,88,59,246,85,223,183,54,130,243,5,109,136,148,23,125,200,150,163,2,201,111,99,81,44,1],"expectedCoordinator":159},{"name":"generated-360-size-3","seedInt64":"-6286753937138067290","attemptNumber":0,"members":[195,248,170],"expectedCoordinator":248},{"name":"generated-361-size-17","seedInt64":"3766005706151771389","attemptNumber":5309,"members":[94,78,157,244,183,186,5,174,119,176,210,211,100,85,166,29,23],"expectedCoordinator":210},{"name":"generated-362-size-79","seedInt64":"-8868431670605151783","attemptNumber":1789228427,"members":[81,28,210,53,40,59,219,254,85,161,49,71,2,18,221,200,12,230,216,8,57,244,84,215,163,245,191,29,238,1,229,193,164,25,168,105,122,32,208,217,15,17,170,139,188,46,235,9,4,101,167,98,236,171,11,68,33,129,253,56,165,162,130,176,179,92,24,104,211,202,16,30,206,72,48,112,62,178,241],"expectedCoordinator":24},{"name":"generated-363-size-170","seedInt64":"2949963395171182635","attemptNumber":4,"members":[189,21,147,103,185,113,181,183,119,24,30,148,210,75,239,83,153,116,88,182,160,100,67,78,255,158,213,190,130,234,77,95,80,211,169,84,19,85,115,35,123,107,13,125,154,90,34,225,202,20,5,226,4,39,82,201,249,46,50,66,250,58,187,136,33,40,176,52,252,171,241,42,8,191,128,9,117,254,112,247,118,60,173,253,37,251,218,29,135,167,54,94,192,164,120,133,134,61,244,7,56,65,57,198,1,41,17,93,204,197,236,10,177,15,70,131,126,138,2,195,91,142,73,76,22,227,159,28,51,6,63,240,72,150,243,206,96,55,246,26,208,215,237,59,106,18,232,11,188,178,179,143,43,110,222,212,141,233,23,122,194,89,109,124,111,14,184,27,102,223],"expectedCoordinator":184},{"name":"generated-364-size-2","seedInt64":"1249634355724197413","attemptNumber":48661,"members":[210,189],"expectedCoordinator":210},{"name":"generated-365-size-17","seedInt64":"479785467363067588","attemptNumber":3253273160,"members":[58,85,69,131,59,234,46,7,199,30,88,150,220,213,163,84,101],"expectedCoordinator":58},{"name":"generated-366-size-93","seedInt64":"381802547119599493","attemptNumber":3,"members":[48,220,15,176,203,117,246,152,211,168,4,233,29,109,153,8,155,46,118,249,43,234,253,81,172,66,226,228,89,99,52,27,205,201,103,218,195,136,202,108,86,42,16,232,214,53,222,208,51,116,194,6,123,219,217,230,241,197,94,147,134,235,45,184,39,88,255,25,112,216,31,36,68,72,248,229,95,240,148,80,177,21,87,59,158,146,13,132,182,119,245,180,60],"expectedCoordinator":229},{"name":"generated-367-size-141","seedInt64":"1977220599232138269","attemptNumber":43837,"members":[164,192,189,122,32,145,83,127,201,52,171,205,163,174,119,135,51,243,232,94,181,137,142,242,203,231,250,3,78,187,176,97,182,150,219,117,30,214,112,208,148,79,173,162,212,244,207,16,27,128,25,31,53,146,9,183,186,188,38,41,220,114,84,123,96,23,132,61,249,108,24,246,76,18,175,109,190,247,120,206,196,2,22,179,1,248,134,81,80,169,124,143,233,236,66,115,91,111,151,202,54,48,144,154,240,177,193,131,218,191,147,165,35,255,19,69,44,49,90,222,59,160,42,118,86,170,26,67,184,226,56,126,241,227,235,253,213,110,178,65,34],"expectedCoordinator":134},{"name":"generated-368-size-2","seedInt64":"-2696281396346576550","attemptNumber":271014858,"members":[85,161],"expectedCoordinator":85},{"name":"generated-369-size-39","seedInt64":"-1455850212059492874","attemptNumber":6,"members":[43,191,239,139,166,143,254,141,155,55,187,123,110,35,5,196,19,95,212,161,151,41,45,247,215,20,64,183,51,127,85,214,149,119,204,107,90,240,182],"expectedCoordinator":191},{"name":"generated-370-size-59","seedInt64":"3762855937347766611","attemptNumber":5947,"members":[19,61,245,158,121,24,29,166,129,93,66,206,56,48,124,153,59,76,234,52,13,77,223,227,232,172,213,3,28,188,233,82,160,195,49,70,91,38,112,41,100,236,239,83,196,224,130,11,242,240,115,185,184,110,94,122,57,87,36],"expectedCoordinator":76},{"name":"generated-371-size-230","seedInt64":"-5198814667512864160","attemptNumber":3013291409,"members":[252,224,169,154,64,134,178,223,10,46,218,216,221,184,40,148,136,91,185,97,59,241,92,180,239,191,18,33,107,116,8,177,143,49,103,255,77,236,158,238,174,16,226,88,168,12,5,6,118,201,205,42,108,20,2,248,44,139,102,246,62,19,157,132,145,119,36,3,156,163,172,186,202,70,124,183,94,78,249,123,63,65,179,47,106,197,23,230,254,147,52,187,210,67,74,115,138,203,211,207,71,84,152,57,213,200,141,208,105,26,39,217,196,41,135,34,188,117,86,66,79,127,222,190,126,161,182,120,113,193,170,110,29,111,82,237,240,112,242,114,35,17,15,149,72,153,121,96,54,45,225,100,24,232,231,48,164,87,214,53,251,192,133,7,233,229,204,175,253,101,104,215,137,69,220,247,38,27,206,131,176,95,68,61,85,55,125,165,209,194,244,155,146,195,219,37,144,173,130,80,21,159,199,160,140,151,228,22,32,89,90,28,56,122,243,198,171,60,25,51,4,189,109,93,50,58,11,14,245,227],"expectedCoordinator":245},{"name":"generated-372-size-2","seedInt64":"3103415963261310271","attemptNumber":0,"members":[186,40],"expectedCoordinator":40},{"name":"generated-373-size-26","seedInt64":"3090710981723483928","attemptNumber":29882,"members":[228,183,132,146,213,145,209,56,191,232,165,252,253,124,22,216,244,195,72,74,251,225,188,239,174,208],"expectedCoordinator":124},{"name":"generated-374-size-58","seedInt64":"-5485097948226718265","attemptNumber":2339049414,"members":[125,252,88,206,171,157,13,194,218,204,12,166,91,45,99,253,3,209,176,141,23,116,4,247,159,104,52,133,130,77,231,90,155,188,59,244,145,202,121,39,58,29,128,43,76,80,63,44,107,34,230,198,112,36,137,25,2,26],"expectedCoordinator":157},{"name":"generated-375-size-245","seedInt64":"6681917197792807262","attemptNumber":6,"members":[215,106,86,116,174,132,145,30,194,25,6,170,228,19,34,222,131,74,227,120,115,16,73,245,13,206,242,232,2,169,50,44,96,21,64,210,252,111,162,133,188,135,248,155,59,136,76,229,36,125,141,1,32,179,209,7,253,46,137,163,53,219,207,230,159,15,119,20,173,18,200,114,251,55,71,108,3,208,226,82,78,93,54,195,246,197,77,113,225,88,178,40,217,233,189,184,243,168,56,35,171,121,235,205,153,142,118,223,87,103,216,193,187,101,39,51,112,146,158,52,83,126,95,143,48,69,247,75,220,183,72,49,98,104,237,110,22,109,134,138,211,161,182,99,180,147,238,66,224,79,148,167,249,154,152,11,176,105,198,181,151,42,23,37,236,31,240,58,29,10,140,117,84,250,14,157,172,127,144,185,100,218,12,43,213,97,160,122,156,203,123,231,26,244,212,239,214,91,61,202,234,150,165,186,130,70,201,33,90,128,129,166,221,175,190,241,254,47,9,192,57,102,80,24,94,27,149,199,63,62,8,89,107,17,65,4,5,191,38,45,164,85,68,92,255],"expectedCoordinator":158},{"name":"generated-376-size-2","seedInt64":"7218074998969327634","attemptNumber":53985,"members":[222,32],"expectedCoordinator":32},{"name":"generated-377-size-20","seedInt64":"2183938391142827029","attemptNumber":506187988,"members":[211,168,173,86,98,204,107,175,170,183,41,242,48,126,63,154,169,78,1,92],"expectedCoordinator":170},{"name":"generated-378-size-92","seedInt64":"-4492447877611075623","attemptNumber":7,"members":[239,240,68,78,4,105,84,51,192,216,190,86,149,8,22,106,76,114,241,54,131,187,39,245,250,207,155,93,202,193,98,183,208,120,65,179,130,75,18,27,74,171,236,132,162,198,36,58,194,197,225,243,176,196,247,137,1,7,21,9,6,79,231,61,203,163,133,81,154,227,53,3,147,185,80,223,12,119,230,229,40,175,26,142,174,151,87,242,213,63,221,219],"expectedCoordinator":114},{"name":"generated-379-size-251","seedInt64":"-7310650097399260988","attemptNumber":6539,"members":[134,160,71,12,180,201,82,23,249,81,52,46,200,61,203,192,245,119,178,79,6,35,122,7,95,213,186,32,22,143,75,62,211,124,156,157,182,8,174,181,103,116,65,31,197,48,239,17,51,42,105,166,219,78,2,183,4,254,126,86,146,142,96,247,252,128,99,222,177,141,152,135,229,189,251,161,29,37,129,50,250,121,104,127,227,140,53,216,98,199,85,206,74,238,215,83,111,84,93,179,137,194,207,164,113,89,136,230,175,144,87,209,188,248,148,246,170,66,60,214,176,27,44,13,38,153,243,187,173,97,241,155,100,139,255,114,232,235,208,72,226,80,240,59,47,55,36,19,9,115,90,244,147,138,10,231,70,14,253,94,198,125,76,88,101,92,171,149,236,165,68,154,106,212,151,3,163,43,25,21,107,234,67,28,223,130,210,73,202,20,132,237,16,162,64,57,193,172,242,54,34,69,184,41,196,39,225,15,24,217,40,167,58,117,56,159,168,102,1,205,190,26,109,30,5,110,91,191,228,150,218,131,112,133,45,63,11,123,195,33,221,224,220,49,204,120,233,158,18,118,169],"expectedCoordinator":148},{"name":"generated-380-size-2","seedInt64":"7781628491165608908","attemptNumber":4262402584,"members":[17,215],"expectedCoordinator":215},{"name":"generated-381-size-50","seedInt64":"7233224353024129826","attemptNumber":3,"members":[184,83,85,68,53,14,84,137,75,47,254,198,138,39,211,90,12,242,11,209,78,239,231,176,26,245,145,232,79,208,212,174,177,193,170,229,95,252,28,3,38,143,248,23,25,160,22,63,16,118],"expectedCoordinator":11},{"name":"generated-382-size-91","seedInt64":"-8323035444829997457","attemptNumber":61339,"members":[211,151,59,79,221,37,76,52,121,167,127,252,47,200,182,220,213,177,74,30,142,187,136,94,238,244,131,96,169,135,40,217,71,201,218,240,237,155,45,148,216,91,106,145,19,165,31,97,68,63,27,236,60,17,128,154,54,57,77,207,36,205,33,171,24,130,126,129,194,203,247,29,193,219,191,58,228,232,11,199,189,204,41,178,227,158,122,166,184,157,48],"expectedCoordinator":47},{"name":"generated-383-size-156","seedInt64":"-4122981267297444425","attemptNumber":78372358,"members":[34,31,32,123,75,182,55,66,126,224,106,219,227,15,137,86,52,215,251,56,175,62,253,96,184,211,46,115,47,82,168,4,64,110,152,246,119,102,41,53,88,17,214,73,151,208,230,113,81,99,252,26,109,43,142,29,139,79,245,193,160,213,6,183,159,158,185,125,90,87,21,250,13,138,149,112,35,194,85,45,181,163,135,48,153,243,100,89,16,144,7,37,77,212,248,174,150,148,165,83,192,187,131,209,186,33,8,141,191,57,101,107,18,225,98,217,3,236,167,91,116,238,140,50,136,201,180,74,220,68,172,117,60,147,240,59,122,206,176,223,235,200,69,61,65,22,177,241,202,23,72,166,71,210,190,27],"expectedCoordinator":230}]} diff --git a/pkg/frost/roast/transition_message.go b/pkg/frost/roast/transition_message.go new file mode 100644 index 0000000000..9079ac7935 --- /dev/null +++ b/pkg/frost/roast/transition_message.go @@ -0,0 +1,603 @@ +package roast + +import ( + "bytes" + "errors" + "fmt" + "sort" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// roastMessageTypePrefix is the per-protocol prefix every ROAST-layer +// wire message uses for its net.TaggedUnmarshaler Type(). Distinct +// from frost_signing/native_frost/ and frost_signing/native_tbtc_signer/ +// so the network router can dispatch unambiguously. +const roastMessageTypePrefix = "frost_signing/roast/" + +// LocalEvidenceSnapshotType is the stable Type() string for a single +// signer's signed evidence snapshot. +const LocalEvidenceSnapshotType = roastMessageTypePrefix + "local_evidence_snapshot" + +// TransitionMessageType is the stable Type() string for the +// coordinator-aggregated bundle. +const TransitionMessageType = roastMessageTypePrefix + "transition_message" + +// MaxSnapshotsPerBundle caps the number of LocalEvidenceSnapshot +// entries a TransitionMessage may carry. Sized for the worst-case +// production signing group plus headroom; rejects pathological +// bundles at Unmarshal time so a misbehaving peer cannot exhaust +// memory on the receiver. +const MaxSnapshotsPerBundle = 256 + +// MaxOperatorSignatureBytes caps the per-snapshot OperatorSignature +// length. Sized to accept secp256k1 DER (~72 bytes), ed25519 (64 +// bytes), and reasonable post-quantum candidates without committing +// to a specific scheme at this layer. Rejects oversize payloads. +const MaxOperatorSignatureBytes = 256 + +// MaxCoordinatorPackageProofs caps the coordinator-signed package proofs a +// snapshot may carry: the authoritative package this signer accepted plus, at +// most, one body-different package proving local coordinator equivocation. +const MaxCoordinatorPackageProofs = 2 + +// MaxCoordinatorSignatureBytes caps the bundle-level +// CoordinatorSignature. Same justification as +// MaxOperatorSignatureBytes. +const MaxCoordinatorSignatureBytes = 256 + +// MaxSignedLocalEvidenceSnapshotBytes bounds a whole SignedLocalEvidenceSnapshot +// envelope so Unmarshal can reject a grossly oversized payload BEFORE the +// protobuf decoder allocates for it (mirroring SigningPackage / ShareSubmission). +// The coordinator package proofs dominate - each up to a full SignedSigningPackage +// - and the fixed 64 KiB allowance generously covers the evidence fields, the +// operator signature, and protobuf framing. +const MaxSignedLocalEvidenceSnapshotBytes = MaxCoordinatorPackageProofs*MaxSignedSigningPackageBytes + + MaxOperatorSignatureBytes + 64*1024 + +// MaxSignedTransitionMessageBytes bounds a whole SignedTransitionMessage envelope +// for the same pre-allocation reason: a full bundle of (now proof-carrying) +// snapshots, the coordinator signature, and protobuf framing. +const MaxSignedTransitionMessageBytes = MaxSnapshotsPerBundle*MaxSignedLocalEvidenceSnapshotBytes + + MaxCoordinatorSignatureBytes + 64*1024 + +// OverflowEntry is the JSON-friendly key/value pair representing one +// per-sender overflow count from an attempt.Evidence map. The slice +// representation is canonical (sorted by Sender ascending) so any +// two honest signers serialising the same evidence produce +// byte-identical JSON. +type OverflowEntry struct { + Sender group.MemberIndex + Count uint +} + +// RejectEntry carries one per-(sender, reason) reject count from an +// attempt.Evidence map. The bundle's Rejects field is sorted +// ascending first by Sender, then by Reason, so two honest signers +// produce byte-identical canonical encodings. +type RejectEntry struct { + Sender group.MemberIndex + Reason string + Count uint +} + +// ConflictEntry carries one per-sender conflict count -- the number +// of first-write-wins disagreements detected during the attempt. +// Sorted ascending by Sender for canonical encoding. +type ConflictEntry struct { + Sender group.MemberIndex + Count uint +} + +// LocalEvidenceSnapshot is the per-signer signed evidence produced +// during a single attempt. It is the input to the coordinator's +// aggregation and to the receiver-side bundle verification. +// +// Phase 3.2 (this file) defines the wire type only. Signature +// computation and verification land in Phase 3.3. +type LocalEvidenceSnapshot struct { + SenderIDValue uint32 + // AttemptContextHash binds the snapshot to the attempt the + // evidence describes. Always exactly 32 bytes. + AttemptContextHash []byte + // Overflows is the canonical sorted form of the + // attempt.Evidence.Overflows map; sorted ascending by Sender. + // Omitted when no overflow events were observed. + Overflows []OverflowEntry + // Rejects is the canonical sorted form of the + // attempt.Evidence.Rejects map; sorted ascending first by Sender, + // then by Reason. Omitted when no validation-reject events were + // observed. Each entry counts the number of rejects observed + // for one (sender, reason) pair, saturated at the recorder's + // reject quota. + Rejects []RejectEntry + // Conflicts is the canonical sorted form of the + // attempt.Evidence.Conflicts map; sorted ascending by Sender. + // Omitted when no first-write-wins-conflict events were + // observed. + Conflicts []ConflictEntry + // CoordinatorPackageProofs carries the coordinator-signed signing-package + // envelope(s) this signer accepted for the attempt (the authoritative one, + // plus a second body-different one if the coordinator equivocated to this + // signer). At most MaxCoordinatorPackageProofs, in canonical bytewise-ascending + // order. They are the raw, unforgeable proof material NextAttempt verifies and + // dedupes by signing-package body hash to exclude a coordinator that signed + // >= 2 distinct bodies for one attempt. Each entry is a serialized + // SignedSigningPackage, carried verbatim; omitted when none. + CoordinatorPackageProofs [][]byte + // OperatorSignature is the signer's operator-key signature over + // SignableBytes(): the snapshot domain tag followed by the serialized + // protobuf body of (senderID, attemptContextHash, overflows, rejects, + // conflicts, coordinatorPackageProofs). + OperatorSignature []byte + + // bodyCache caches the exact serialized body bytes carried on the wire: + // marshaled once at signing time for self-authored snapshots, or the + // received bytes verbatim for parsed ones. Evidence fields must not be + // mutated once set. + bodyCache []byte + // signaturePayloadCache caches the domain-tagged bytes the + // OperatorSignature covers (localEvidenceSnapshotSignatureDomain || + // bodyCache); rebuilt from bodyCache and never carried on the wire. + signaturePayloadCache []byte + // wireEnvelope caches the exact on-wire envelope (body + + // signature): the received bytes verbatim for parsed snapshots, + // or built once after signing for self-authored ones. + wireEnvelope []byte +} + +// NewLocalEvidenceSnapshot converts an attempt.Evidence map into a +// LocalEvidenceSnapshot ready for signing and broadcast. The evidence fields +// are sorted into their canonical order; any coordinatorPackageProofs (the +// coordinator-signed signing-package envelope(s) this signer accepted) are +// stored as owned copies in canonical bytewise-ascending order. The +// OperatorSignature is left empty -- the caller must sign and populate it. +func NewLocalEvidenceSnapshot( + sender group.MemberIndex, + attemptContextHash [attempt.MessageDigestLength]byte, + evidence attempt.Evidence, + coordinatorPackageProofs ...[]byte, +) *LocalEvidenceSnapshot { + overflows := make([]OverflowEntry, 0, len(evidence.Overflows)) + for s, c := range evidence.Overflows { + overflows = append(overflows, OverflowEntry{Sender: s, Count: c}) + } + sort.Slice(overflows, func(i, j int) bool { + return overflows[i].Sender < overflows[j].Sender + }) + + rejects := make([]RejectEntry, 0) + for s, entries := range evidence.Rejects { + for _, e := range entries { + rejects = append(rejects, RejectEntry{ + Sender: s, + Reason: e.Reason, + Count: e.Count, + }) + } + } + sort.Slice(rejects, func(i, j int) bool { + if rejects[i].Sender != rejects[j].Sender { + return rejects[i].Sender < rejects[j].Sender + } + return rejects[i].Reason < rejects[j].Reason + }) + + conflicts := make([]ConflictEntry, 0, len(evidence.Conflicts)) + for s, c := range evidence.Conflicts { + conflicts = append(conflicts, ConflictEntry{Sender: s, Count: c}) + } + sort.Slice(conflicts, func(i, j int) bool { + return conflicts[i].Sender < conflicts[j].Sender + }) + + snap := &LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, attemptContextHash[:]...), + Overflows: overflows, + } + if len(rejects) > 0 { + snap.Rejects = rejects + } + if len(conflicts) > 0 { + snap.Conflicts = conflicts + } + if len(coordinatorPackageProofs) > 0 { + proofs := make([][]byte, len(coordinatorPackageProofs)) + for i, p := range coordinatorPackageProofs { + proofs[i] = append([]byte(nil), p...) + } + // Canonical bytewise-ascending order so the signed body is deterministic + // across signers; NextAttempt dedupes by body hash regardless of order. + sort.Slice(proofs, func(i, j int) bool { + return bytes.Compare(proofs[i], proofs[j]) < 0 + }) + snap.CoordinatorPackageProofs = proofs + } + return snap +} + +// SenderID returns the snapshot's sender as a group.MemberIndex. +func (s *LocalEvidenceSnapshot) SenderID() group.MemberIndex { + return group.MemberIndex(s.SenderIDValue) +} + +// AttemptContextHashArray returns the 32-byte attempt context hash +// as a fixed-size array. Returns the zero array if the field is +// malformed (caller should have validated via Unmarshal first). +func (s *LocalEvidenceSnapshot) AttemptContextHashArray() [attempt.MessageDigestLength]byte { + var out [attempt.MessageDigestLength]byte + if len(s.AttemptContextHash) == attempt.MessageDigestLength { + copy(out[:], s.AttemptContextHash) + } + return out +} + +// Evidence reconstructs the attempt.Evidence map form from the +// canonical sorted-slice representation. The returned Evidence +// shares no state with the snapshot. +func (s *LocalEvidenceSnapshot) Evidence() attempt.Evidence { + out := attempt.Evidence{ + Overflows: make(map[group.MemberIndex]uint, len(s.Overflows)), + Rejects: make(map[group.MemberIndex][]attempt.RejectEntry, 0), + Conflicts: make(map[group.MemberIndex]uint, len(s.Conflicts)), + } + for _, e := range s.Overflows { + out.Overflows[e.Sender] = e.Count + } + for _, e := range s.Rejects { + out.Rejects[e.Sender] = append(out.Rejects[e.Sender], attempt.RejectEntry{ + Reason: e.Reason, + Count: e.Count, + }) + } + for _, e := range s.Conflicts { + out.Conflicts[e.Sender] = e.Count + } + return out +} + +// Type implements net.TaggedUnmarshaler. +func (s *LocalEvidenceSnapshot) Type() string { + return LocalEvidenceSnapshotType +} + +// Marshal serialises the snapshot as a SignedLocalEvidenceSnapshot +// envelope: the exact signed body bytes plus the operator signature. +// For a snapshot parsed off the wire the received envelope is +// returned verbatim, so evidence bytes survive any re-broadcast +// unchanged. The snapshot must be signed first. The returned slice is +// the internal cache - callers must not mutate it. +func (s *LocalEvidenceSnapshot) Marshal() ([]byte, error) { + return s.wireEnvelopeBytes() +} + +// Unmarshal parses a SignedLocalEvidenceSnapshot envelope, retains +// the received body and envelope bytes verbatim (signature +// verification runs over exactly these bytes), populates the +// evidence fields from the body, and validates the structure. +func (s *LocalEvidenceSnapshot) Unmarshal(data []byte) error { + // Reject a grossly oversized envelope before the protobuf decoder allocates + // for it: the carried coordinator package proofs make a legitimate snapshot + // MBs large, so an unbounded decode is a memory-DoS vector. + if len(data) > MaxSignedLocalEvidenceSnapshotBytes { + return fmt.Errorf( + "local evidence snapshot: envelope length [%d] exceeds cap [%d]", + len(data), + MaxSignedLocalEvidenceSnapshotBytes, + ) + } + var envelope pb.SignedLocalEvidenceSnapshot + if err := proto.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("local evidence snapshot: parse envelope: %w", err) + } + if len(envelope.Body) == 0 { + return errors.New("local evidence snapshot: empty body") + } + var body pb.LocalEvidenceSnapshotBody + if err := proto.Unmarshal(envelope.Body, &body); err != nil { + return fmt.Errorf("local evidence snapshot: parse body: %w", err) + } + snapshotFieldsFromBody(s, &body) + s.OperatorSignature = append([]byte(nil), envelope.OperatorSignature...) + s.bodyCache = append([]byte(nil), envelope.Body...) + s.wireEnvelope = append([]byte(nil), data...) + // Prime the signable-bytes cache from the body just received, discarding any + // cache a prior SignableBytes call left on a reused value. Priming here - + // rather than lazily in SignableBytes - keeps concurrent signature + // verification of a parsed snapshot race-free: verifiers read a ready cache + // instead of racing on lazy initialization. + s.signaturePayloadCache = nil + if _, err := s.SignableBytes(); err != nil { + return err + } + return s.Validate() +} + +// Validate runs the structural checks Unmarshal applies after a +// decode. Exposed publicly so callers that construct snapshots in +// memory (e.g. the Coordinator state machine) can validate without +// a marshal/unmarshal round-trip. +func (s *LocalEvidenceSnapshot) Validate() error { + if s.SenderIDValue == 0 { + return errors.New("local evidence snapshot: senderID is zero") + } + if len(s.AttemptContextHash) != attempt.MessageDigestLength { + return fmt.Errorf( + "local evidence snapshot: attemptContextHash length [%d], expected [%d]", + len(s.AttemptContextHash), + attempt.MessageDigestLength, + ) + } + if len(s.OperatorSignature) > MaxOperatorSignatureBytes { + return fmt.Errorf( + "local evidence snapshot: operatorSignature length [%d] exceeds cap [%d]", + len(s.OperatorSignature), + MaxOperatorSignatureBytes, + ) + } + for i := 1; i < len(s.Overflows); i++ { + if s.Overflows[i].Sender <= s.Overflows[i-1].Sender { + return fmt.Errorf( + "local evidence snapshot: overflows not sorted ascending or contain duplicate at index %d", + i, + ) + } + } + for i := 1; i < len(s.Rejects); i++ { + prev := s.Rejects[i-1] + cur := s.Rejects[i] + if cur.Sender < prev.Sender { + return fmt.Errorf( + "local evidence snapshot: rejects not sorted ascending by sender at index %d", + i, + ) + } + if cur.Sender == prev.Sender && cur.Reason <= prev.Reason { + return fmt.Errorf( + "local evidence snapshot: rejects not sorted ascending by reason or contain duplicate at index %d", + i, + ) + } + } + for i := 1; i < len(s.Conflicts); i++ { + if s.Conflicts[i].Sender <= s.Conflicts[i-1].Sender { + return fmt.Errorf( + "local evidence snapshot: conflicts not sorted ascending or contain duplicate at index %d", + i, + ) + } + } + if len(s.CoordinatorPackageProofs) > MaxCoordinatorPackageProofs { + return fmt.Errorf( + "local evidence snapshot: coordinatorPackageProofs count [%d] exceeds cap [%d]", + len(s.CoordinatorPackageProofs), + MaxCoordinatorPackageProofs, + ) + } + for i, proof := range s.CoordinatorPackageProofs { + if len(proof) == 0 { + return fmt.Errorf( + "local evidence snapshot: coordinatorPackageProofs[%d] is empty", + i, + ) + } + if len(proof) > MaxSignedSigningPackageBytes { + return fmt.Errorf( + "local evidence snapshot: coordinatorPackageProofs[%d] length [%d] exceeds cap [%d]", + i, + len(proof), + MaxSignedSigningPackageBytes, + ) + } + if i > 0 && bytes.Compare(proof, s.CoordinatorPackageProofs[i-1]) <= 0 { + return fmt.Errorf( + "local evidence snapshot: coordinatorPackageProofs not sorted ascending or contain duplicate at index %d", + i, + ) + } + } + return nil +} + +// TransitionMessage is the coordinator-aggregated bundle that drives +// the deterministic NextAttempt transition. It contains every +// participating signer's signed evidence snapshot for one attempt, +// plus the coordinator's own signature over the canonical bundle. +// +// Phase 3.2 (this file) defines the wire type. Aggregation, +// canonical encoding, and verification land in Phase 3.3. +type TransitionMessage struct { + // AttemptContextHash identifies the attempt the bundle + // describes. Must match every snapshot's AttemptContextHash. + // Always exactly 32 bytes. + AttemptContextHash []byte + // CoordinatorIDValue is the member index of the elected + // coordinator that produced this bundle. + CoordinatorIDValue uint32 + // Bundle is the canonical sorted-by-SenderID list of signed + // evidence snapshots aggregated by the coordinator. + Bundle []LocalEvidenceSnapshot + // CoordinatorSignature is the coordinator's operator-key signature over + // SignableBytes(): the transition domain tag followed by the serialized + // protobuf body embedding every snapshot's signed envelope verbatim. + CoordinatorSignature []byte + + // bodyCache, signaturePayloadCache, and wireEnvelope cache exact bytes + // with the same semantics as the LocalEvidenceSnapshot caches. + bodyCache []byte + signaturePayloadCache []byte + wireEnvelope []byte +} + +// CoordinatorID returns the coordinator member index as a +// group.MemberIndex. +func (m *TransitionMessage) CoordinatorID() group.MemberIndex { + return group.MemberIndex(m.CoordinatorIDValue) +} + +// AttemptContextHashArray returns the 32-byte attempt context hash +// as a fixed-size array. Returns the zero array if the field is +// malformed (caller should have validated via Unmarshal first). +func (m *TransitionMessage) AttemptContextHashArray() [attempt.MessageDigestLength]byte { + var out [attempt.MessageDigestLength]byte + if len(m.AttemptContextHash) == attempt.MessageDigestLength { + copy(out[:], m.AttemptContextHash) + } + return out +} + +// Type implements net.TaggedUnmarshaler. +func (m *TransitionMessage) Type() string { + return TransitionMessageType +} + +// Marshal serialises the message as a SignedTransitionMessage +// envelope: the exact signed body bytes plus the coordinator +// signature. For a message parsed off the wire the received envelope +// is returned verbatim. The message must be signed first. The +// returned slice is the internal cache - callers must not mutate it. +func (m *TransitionMessage) Marshal() ([]byte, error) { + if m.wireEnvelope != nil { + return m.wireEnvelope, nil + } + if len(m.CoordinatorSignature) == 0 { + return nil, errors.New( + "transition message: must be signed before wire encoding", + ) + } + body, err := m.bodyBytes() + if err != nil { + return nil, err + } + envelope, err := proto.Marshal(&pb.SignedTransitionMessage{ + Body: body, + CoordinatorSignature: m.CoordinatorSignature, + }) + if err != nil { + return nil, fmt.Errorf("transition message: marshal envelope: %w", err) + } + m.wireEnvelope = envelope + return envelope, nil +} + +// Unmarshal parses a SignedTransitionMessage envelope, retains the +// received body and envelope bytes verbatim (the coordinator +// signature verifies over exactly these bytes), parses each embedded +// snapshot envelope (each retaining its own received bytes), and +// validates the structure: hash length, bundle size cap, signature +// size cap, snapshot validity, bundle ordering by SenderID +// ascending, and every snapshot binding to the same +// AttemptContextHash as the bundle. +func (m *TransitionMessage) Unmarshal(data []byte) error { + // Reject a grossly oversized envelope before the protobuf decoder allocates + // for it (each bundled snapshot may now carry coordinator package proofs). + if len(data) > MaxSignedTransitionMessageBytes { + return fmt.Errorf( + "transition message: envelope length [%d] exceeds cap [%d]", + len(data), + MaxSignedTransitionMessageBytes, + ) + } + var envelope pb.SignedTransitionMessage + if err := proto.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("transition message: parse envelope: %w", err) + } + if len(envelope.Body) == 0 { + return errors.New("transition message: empty body") + } + var body pb.TransitionMessageBody + if err := proto.Unmarshal(envelope.Body, &body); err != nil { + return fmt.Errorf("transition message: parse body: %w", err) + } + if len(body.SignedSnapshots) > MaxSnapshotsPerBundle { + return fmt.Errorf( + "transition message: bundle length [%d] exceeds cap [%d]", + len(body.SignedSnapshots), + MaxSnapshotsPerBundle, + ) + } + m.AttemptContextHash = append([]byte(nil), body.AttemptContextHash...) + m.CoordinatorIDValue = body.CoordinatorId + m.Bundle = make([]LocalEvidenceSnapshot, 0, len(body.SignedSnapshots)) + for i, raw := range body.SignedSnapshots { + var snapshot LocalEvidenceSnapshot + if err := snapshot.Unmarshal(raw); err != nil { + return fmt.Errorf("transition message: bundle[%d]: %w", i, err) + } + m.Bundle = append(m.Bundle, snapshot) + } + m.CoordinatorSignature = append([]byte(nil), envelope.CoordinatorSignature...) + m.bodyCache = append([]byte(nil), envelope.Body...) + m.wireEnvelope = append([]byte(nil), data...) + // Prime the signable-bytes cache so concurrent verification of a parsed + // bundle is race-free, and discard any stale cache on a reused value (see + // LocalEvidenceSnapshot.Unmarshal). + m.signaturePayloadCache = nil + if _, err := m.SignableBytes(); err != nil { + return err + } + return m.Validate() +} + +// Validate runs the structural checks Unmarshal applies after a +// decode: bundle hash length, bundle size cap, coordinator id, every +// snapshot's validity, bundle ordering, and intra-bundle hash +// consistency. Exposed publicly so callers that construct messages +// in memory can validate without a marshal/unmarshal round-trip. +func (m *TransitionMessage) Validate() error { + if len(m.AttemptContextHash) != attempt.MessageDigestLength { + return fmt.Errorf( + "transition message: attemptContextHash length [%d], expected [%d]", + len(m.AttemptContextHash), + attempt.MessageDigestLength, + ) + } + if m.CoordinatorIDValue == 0 { + return errors.New("transition message: coordinatorID is zero") + } + if len(m.Bundle) == 0 { + return errors.New("transition message: bundle must not be empty") + } + if len(m.Bundle) > MaxSnapshotsPerBundle { + return fmt.Errorf( + "transition message: bundle length [%d] exceeds cap [%d]", + len(m.Bundle), + MaxSnapshotsPerBundle, + ) + } + if len(m.CoordinatorSignature) > MaxCoordinatorSignatureBytes { + return fmt.Errorf( + "transition message: coordinatorSignature length [%d] exceeds cap [%d]", + len(m.CoordinatorSignature), + MaxCoordinatorSignatureBytes, + ) + } + for i := range m.Bundle { + if err := m.Bundle[i].Validate(); err != nil { + return fmt.Errorf( + "transition message: bundle[%d] invalid: %w", + i, err, + ) + } + if !bytes.Equal(m.Bundle[i].AttemptContextHash, m.AttemptContextHash) { + return fmt.Errorf( + "transition message: bundle[%d] attempt context hash does not match bundle hash", + i, + ) + } + if i > 0 { + if m.Bundle[i].SenderIDValue <= m.Bundle[i-1].SenderIDValue { + return fmt.Errorf( + "transition message: bundle not sorted ascending by senderID or contains duplicate at index %d", + i, + ) + } + } + } + return nil +} diff --git a/pkg/frost/roast/transition_message_test.go b/pkg/frost/roast/transition_message_test.go new file mode 100644 index 0000000000..3c21fb5664 --- /dev/null +++ b/pkg/frost/roast/transition_message_test.go @@ -0,0 +1,437 @@ +package roast + +import ( + "bytes" + "strings" + "testing" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// encodeSnapshotForTest builds a SignedLocalEvidenceSnapshot envelope from +// arbitrary (possibly invalid) in-memory fields, bypassing the production +// signing/validation path, so tests can exercise Unmarshal's rejection of +// structurally invalid wire bytes. +func encodeSnapshotForTest(t *testing.T, s *LocalEvidenceSnapshot) []byte { + t.Helper() + body, err := proto.Marshal(snapshotBodyMessage(s)) + if err != nil { + t.Fatalf("encode snapshot body: %v", err) + } + envelope, err := proto.Marshal(&pb.SignedLocalEvidenceSnapshot{ + Body: body, + OperatorSignature: s.OperatorSignature, + }) + if err != nil { + t.Fatalf("encode snapshot envelope: %v", err) + } + return envelope +} + +// encodeTransitionForTest builds a SignedTransitionMessage envelope from +// arbitrary (possibly invalid) in-memory fields; see encodeSnapshotForTest. +func encodeTransitionForTest(t *testing.T, m *TransitionMessage) []byte { + t.Helper() + body := &pb.TransitionMessageBody{ + AttemptContextHash: m.AttemptContextHash, + CoordinatorId: m.CoordinatorIDValue, + } + for i := range m.Bundle { + body.SignedSnapshots = append( + body.SignedSnapshots, + encodeSnapshotForTest(t, &m.Bundle[i]), + ) + } + bodyBytes, err := proto.Marshal(body) + if err != nil { + t.Fatalf("encode transition body: %v", err) + } + envelope, err := proto.Marshal(&pb.SignedTransitionMessage{ + Body: bodyBytes, + CoordinatorSignature: m.CoordinatorSignature, + }) + if err != nil { + t.Fatalf("encode transition envelope: %v", err) + } + return envelope +} + +var pinnedContextHash = [attempt.MessageDigestLength]byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, +} + +func TestLocalEvidenceSnapshot_TypeIsStable(t *testing.T) { + s := &LocalEvidenceSnapshot{} + if got := s.Type(); got != LocalEvidenceSnapshotType { + t.Fatalf("Type() = %q, want %q", got, LocalEvidenceSnapshotType) + } + if !strings.HasPrefix(LocalEvidenceSnapshotType, roastMessageTypePrefix) { + t.Fatalf( + "Type() must be under the %q prefix; got %q", + roastMessageTypePrefix, LocalEvidenceSnapshotType, + ) + } +} + +func TestNewLocalEvidenceSnapshot_SortsOverflows(t *testing.T) { + evidence := attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{ + 5: 3, + 1: 2, + 3: 1, + }, + } + s := NewLocalEvidenceSnapshot(7, pinnedContextHash, evidence) + + if len(s.Overflows) != 3 { + t.Fatalf("expected 3 overflow entries, got %d", len(s.Overflows)) + } + for i := 1; i < len(s.Overflows); i++ { + if s.Overflows[i].Sender <= s.Overflows[i-1].Sender { + t.Fatalf( + "overflows not sorted ascending at index %d: %v", + i, s.Overflows, + ) + } + } + if s.SenderIDValue != 7 { + t.Fatalf("SenderIDValue = %d, want 7", s.SenderIDValue) + } + if !bytes.Equal(s.AttemptContextHash, pinnedContextHash[:]) { + t.Fatalf( + "AttemptContextHash mismatch: got %x want %x", + s.AttemptContextHash, pinnedContextHash[:], + ) + } +} + +func TestNewLocalEvidenceSnapshot_EmptyEvidenceOmitsOverflows(t *testing.T) { + s := NewLocalEvidenceSnapshot(1, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{}, + }) + if len(s.Overflows) != 0 { + t.Fatalf("expected empty overflows, got %v", s.Overflows) + } + s.OperatorSignature = bytes.Repeat([]byte{0xab}, 64) + data, err := s.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(decoded.Overflows) != 0 { + t.Fatalf( + "empty overflows must stay empty through the wire; got %v", + decoded.Overflows, + ) + } +} + +func TestLocalEvidenceSnapshot_RoundTrip(t *testing.T) { + original := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{ + 1: 2, + 3: 1, + 5: 3, + }, + }) + original.OperatorSignature = bytes.Repeat([]byte{0xab}, 64) + + data, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.SenderIDValue != original.SenderIDValue { + t.Fatalf("sender mismatch") + } + if !bytes.Equal(decoded.AttemptContextHash, original.AttemptContextHash) { + t.Fatalf("attempt context hash mismatch") + } + if len(decoded.Overflows) != len(original.Overflows) { + t.Fatalf( + "overflow length mismatch: %d vs %d", + len(decoded.Overflows), len(original.Overflows), + ) + } + if !bytes.Equal(decoded.OperatorSignature, original.OperatorSignature) { + t.Fatalf("signature mismatch") + } +} + +func TestLocalEvidenceSnapshot_RejectsZeroSender(t *testing.T) { + s := &LocalEvidenceSnapshot{ + SenderIDValue: 0, + AttemptContextHash: pinnedContextHash[:], + } + data := encodeSnapshotForTest(t, s) + err := (&LocalEvidenceSnapshot{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "senderID is zero") { + t.Fatalf("expected zero-sender error, got %v", err) + } +} + +func TestLocalEvidenceSnapshot_RejectsWrongHashLength(t *testing.T) { + bad := encodeSnapshotForTest(t, &LocalEvidenceSnapshot{ + SenderIDValue: 1, + AttemptContextHash: []byte{0x00, 0x01, 0x02}, + }) + err := (&LocalEvidenceSnapshot{}).Unmarshal(bad) + if err == nil || !strings.Contains(err.Error(), "attemptContextHash length") { + t.Fatalf("expected hash-length error, got %v", err) + } +} + +func TestLocalEvidenceSnapshot_RejectsOversizeSignature(t *testing.T) { + s := NewLocalEvidenceSnapshot(1, pinnedContextHash, attempt.Evidence{}) + s.OperatorSignature = bytes.Repeat([]byte{0xff}, MaxOperatorSignatureBytes+1) + data := encodeSnapshotForTest(t, s) + err := (&LocalEvidenceSnapshot{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "exceeds cap") { + t.Fatalf("expected signature-cap error, got %v", err) + } +} + +func TestLocalEvidenceSnapshot_RejectsUnsortedOverflows(t *testing.T) { + bad := &LocalEvidenceSnapshot{ + SenderIDValue: 1, + AttemptContextHash: pinnedContextHash[:], + Overflows: []OverflowEntry{ + {Sender: 5, Count: 1}, + {Sender: 1, Count: 1}, + }, + } + data := encodeSnapshotForTest(t, bad) + err := (&LocalEvidenceSnapshot{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "not sorted") { + t.Fatalf("expected sort error, got %v", err) + } +} + +func TestLocalEvidenceSnapshot_RejectsDuplicateOverflowSender(t *testing.T) { + bad := &LocalEvidenceSnapshot{ + SenderIDValue: 1, + AttemptContextHash: pinnedContextHash[:], + Overflows: []OverflowEntry{ + {Sender: 3, Count: 1}, + {Sender: 3, Count: 1}, + }, + } + data := encodeSnapshotForTest(t, bad) + err := (&LocalEvidenceSnapshot{}).Unmarshal(data) + if err == nil { + t.Fatal("expected duplicate-sender error") + } +} + +func TestLocalEvidenceSnapshot_EvidenceReconstructsMap(t *testing.T) { + original := attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 2, 3: 4}, + } + s := NewLocalEvidenceSnapshot(7, pinnedContextHash, original) + got := s.Evidence() + if len(got.Overflows) != len(original.Overflows) { + t.Fatalf( + "map size mismatch: got %d want %d", + len(got.Overflows), len(original.Overflows), + ) + } + for k, v := range original.Overflows { + if got.Overflows[k] != v { + t.Fatalf("overflow[%d]: got %d want %d", k, got.Overflows[k], v) + } + } +} + +func TestLocalEvidenceSnapshot_AttemptContextHashArrayHandlesMalformed(t *testing.T) { + s := &LocalEvidenceSnapshot{AttemptContextHash: []byte{0x01, 0x02}} + arr := s.AttemptContextHashArray() + var zero [attempt.MessageDigestLength]byte + if arr != zero { + t.Fatalf("expected zero array for malformed hash, got %x", arr) + } +} + +func TestTransitionMessage_TypeIsStable(t *testing.T) { + m := &TransitionMessage{} + if got := m.Type(); got != TransitionMessageType { + t.Fatalf("Type() = %q, want %q", got, TransitionMessageType) + } + if !strings.HasPrefix(TransitionMessageType, roastMessageTypePrefix) { + t.Fatalf("type prefix mismatch: %q", TransitionMessageType) + } +} + +func TestTransitionMessage_RoundTrip(t *testing.T) { + m := buildValidTransitionMessage() + data, err := m.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &TransitionMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.CoordinatorIDValue != m.CoordinatorIDValue { + t.Fatalf("coordinator id mismatch") + } + if len(decoded.Bundle) != len(m.Bundle) { + t.Fatalf( + "bundle size mismatch: %d vs %d", + len(decoded.Bundle), len(m.Bundle), + ) + } + for i := range decoded.Bundle { + if decoded.Bundle[i].SenderIDValue != m.Bundle[i].SenderIDValue { + t.Fatalf("bundle[%d] sender mismatch", i) + } + } +} + +func TestTransitionMessage_RejectsBadBundleOrdering(t *testing.T) { + m := buildValidTransitionMessage() + // Swap order to make it unsorted. + m.Bundle[0], m.Bundle[1] = m.Bundle[1], m.Bundle[0] + data := encodeTransitionForTest(t, m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "not sorted") { + t.Fatalf("expected sort error, got %v", err) + } +} + +func TestTransitionMessage_RejectsMismatchedBundleHash(t *testing.T) { + m := buildValidTransitionMessage() + // Mutate the first bundled snapshot's hash so it disagrees + // with the bundle-level hash. + m.Bundle[0].AttemptContextHash = make([]byte, attempt.MessageDigestLength) + for i := range m.Bundle[0].AttemptContextHash { + m.Bundle[0].AttemptContextHash[i] = 0xff + } + data := encodeTransitionForTest(t, m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "does not match bundle hash") { + t.Fatalf("expected hash-mismatch error, got %v", err) + } +} + +func TestTransitionMessage_RejectsEmptyBundle(t *testing.T) { + m := buildValidTransitionMessage() + m.Bundle = nil + data := encodeTransitionForTest(t, m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "must not be empty") { + t.Fatalf("expected empty-bundle error, got %v", err) + } +} + +func TestTransitionMessage_RejectsOversizeBundle(t *testing.T) { + m := buildValidTransitionMessage() + // Grow bundle beyond the cap by duplicating with monotonically + // increasing senders. + m.Bundle = make([]LocalEvidenceSnapshot, MaxSnapshotsPerBundle+1) + for i := range m.Bundle { + m.Bundle[i] = LocalEvidenceSnapshot{ + SenderIDValue: uint32(i + 1), + AttemptContextHash: append([]byte{}, m.AttemptContextHash...), + } + } + data := encodeTransitionForTest(t, m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "exceeds cap") { + t.Fatalf("expected oversize-bundle error, got %v", err) + } +} + +func TestTransitionMessage_RejectsZeroCoordinatorID(t *testing.T) { + m := buildValidTransitionMessage() + m.CoordinatorIDValue = 0 + data := encodeTransitionForTest(t, m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "coordinatorID is zero") { + t.Fatalf("expected zero-coordinator error, got %v", err) + } +} + +func TestTransitionMessage_RejectsOversizeCoordinatorSignature(t *testing.T) { + m := buildValidTransitionMessage() + m.CoordinatorSignature = bytes.Repeat([]byte{0xff}, MaxCoordinatorSignatureBytes+1) + data := encodeTransitionForTest(t, m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "exceeds cap") { + t.Fatalf("expected oversize-signature error, got %v", err) + } +} + +func TestTransitionMessage_RejectsBundleWithInvalidSnapshot(t *testing.T) { + m := buildValidTransitionMessage() + m.Bundle[0].SenderIDValue = 0 + data := encodeTransitionForTest(t, m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "senderID is zero") { + t.Fatalf("expected invalid-snapshot error, got %v", err) + } +} + +func TestTransitionMessage_RejectsDuplicateBundleSender(t *testing.T) { + m := buildValidTransitionMessage() + m.Bundle[1].SenderIDValue = m.Bundle[0].SenderIDValue + data := encodeTransitionForTest(t, m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil { + t.Fatal("expected duplicate-sender error") + } +} + +func TestTransitionMessage_DeterministicEncodingForIdenticalInputs(t *testing.T) { + a := buildValidTransitionMessage() + b := buildValidTransitionMessage() + dataA, err := a.Marshal() + if err != nil { + t.Fatalf("marshal a: %v", err) + } + dataB, err := b.Marshal() + if err != nil { + t.Fatalf("marshal b: %v", err) + } + if !bytes.Equal(dataA, dataB) { + t.Fatalf( + "identical inputs produced different wire bytes:\n a=%x\n b=%x", + dataA, dataB, + ) + } +} + +func buildValidTransitionMessage() *TransitionMessage { + mkSnap := func(sender group.MemberIndex) LocalEvidenceSnapshot { + return LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, pinnedContextHash[:]...), + Overflows: []OverflowEntry{ + {Sender: 99, Count: 1}, + }, + OperatorSignature: bytes.Repeat([]byte{0xab}, 64), + } + } + return &TransitionMessage{ + AttemptContextHash: append([]byte{}, pinnedContextHash[:]...), + CoordinatorIDValue: 1, + Bundle: []LocalEvidenceSnapshot{ + mkSnap(1), + mkSnap(2), + mkSnap(3), + }, + CoordinatorSignature: bytes.Repeat([]byte{0xee}, 64), + } +} diff --git a/pkg/frost/roast/wire.go b/pkg/frost/roast/wire.go new file mode 100644 index 0000000000..1ef798963a --- /dev/null +++ b/pkg/frost/roast/wire.go @@ -0,0 +1,247 @@ +package roast + +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// Evidence wire format: signed-body protobuf envelopes. +// +// Operator and coordinator signatures cover exact serialized body bytes, +// and those bytes travel verbatim - a verifier checks the signature over +// the bytes it received and only then parses them. Nothing in the +// evidence chain is ever re-encoded, so signature validity never depends +// on any serializer's canonical form, across protobuf library versions or +// across languages. Producers marshal a body exactly once (at signing +// time) and cache it; parsed messages cache the received bytes. +// +// Domain separation. Several signed bodies share the node's operator key and +// are structurally similar - each carries an attempt-context hash and a member +// index. The TransitionMessageBody and the signing-package body are outright +// wire-compatible (both have attempt_context_hash as a length-delimited field +// 1). The LocalEvidenceSnapshotBody is only INCIDENTALLY distinguished - its +// field 1 is sender_id (a varint), not a length-delimited field - a difference +// a later proto change could erase. So a signature over one body must not be +// acceptable as a signature over another. Each signed-body type therefore +// prepends a UNIQUE domain tag to the bytes it signs and verifies +// (SignableBytes), making the separation intentional rather than incidental, +// while the body that travels on the wire stays the bare serialized body +// (bodyBytes). +// +// Each tag BEGINS with byte 0x00 - an illegal protobuf tag (field number 0) - +// so the signed payload is undecodable as any protobuf message. That separates +// the domains in both directions without relying on field layout: a signature +// over one body cannot be replayed onto another envelope (whose decoder +// proto.Unmarshals and rejects the 0x00-leading body), and a genuine +// serialized protobuf body always begins with a valid tag (>= 0x08), so its +// signature can never verify against domain || body. The tags are NOT carried +// on the wire; signer and verifier prepend the same constant. (The signed +// signing-package envelope follows the same scheme with its own tag.) +var ( + localEvidenceSnapshotSignatureDomain = []byte("\x00roast/signed-evidence-snapshot/v1\x00") + transitionMessageSignatureDomain = []byte("\x00roast/signed-transition-message/v1\x00") +) + +func snapshotBodyMessage(s *LocalEvidenceSnapshot) *pb.LocalEvidenceSnapshotBody { + body := &pb.LocalEvidenceSnapshotBody{ + SenderId: s.SenderIDValue, + AttemptContextHash: s.AttemptContextHash, + } + for _, e := range s.Overflows { + body.Overflows = append(body.Overflows, &pb.OverflowEntry{ + Sender: uint32(e.Sender), + Count: uint64(e.Count), + }) + } + for _, e := range s.Rejects { + body.Rejects = append(body.Rejects, &pb.RejectEntry{ + Sender: uint32(e.Sender), + Reason: e.Reason, + Count: uint64(e.Count), + }) + } + for _, e := range s.Conflicts { + body.Conflicts = append(body.Conflicts, &pb.ConflictEntry{ + Sender: uint32(e.Sender), + Count: uint64(e.Count), + }) + } + // Carried verbatim; canonical order + bounds are enforced by Validate. Nil + // when none, so a proof-free snapshot encodes exactly as before. + body.CoordinatorPackageProofs = s.CoordinatorPackageProofs + return body +} + +func snapshotFieldsFromBody(s *LocalEvidenceSnapshot, body *pb.LocalEvidenceSnapshotBody) { + s.SenderIDValue = body.SenderId + s.AttemptContextHash = append([]byte(nil), body.AttemptContextHash...) + s.Overflows = nil + for _, e := range body.Overflows { + s.Overflows = append(s.Overflows, OverflowEntry{ + Sender: group.MemberIndex(e.Sender), + Count: uint(e.Count), + }) + } + s.Rejects = nil + for _, e := range body.Rejects { + s.Rejects = append(s.Rejects, RejectEntry{ + Sender: group.MemberIndex(e.Sender), + Reason: e.Reason, + Count: uint(e.Count), + }) + } + s.Conflicts = nil + for _, e := range body.Conflicts { + s.Conflicts = append(s.Conflicts, ConflictEntry{ + Sender: group.MemberIndex(e.Sender), + Count: uint(e.Count), + }) + } + s.CoordinatorPackageProofs = nil + for _, proof := range body.CoordinatorPackageProofs { + s.CoordinatorPackageProofs = append( + s.CoordinatorPackageProofs, + append([]byte(nil), proof...), + ) + } +} + +// bodyBytes returns the exact serialized LocalEvidenceSnapshotBody - the body +// carried verbatim in the SignedLocalEvidenceSnapshot envelope. Marshaled once +// and cached for a self-authored snapshot; the received bytes verbatim for a +// parsed one. The returned slice is the internal cache - callers must not +// mutate it. +func (s *LocalEvidenceSnapshot) bodyBytes() ([]byte, error) { + if s == nil { + return nil, errors.New("roast: cannot encode a nil snapshot") + } + if s.bodyCache != nil { + return s.bodyCache, nil + } + body, err := proto.Marshal(snapshotBodyMessage(s)) + if err != nil { + return nil, fmt.Errorf("roast: marshal snapshot body: %w", err) + } + s.bodyCache = body + return body, nil +} + +// SignableBytes returns the exact byte stream the OperatorSignature covers: +// the snapshot domain tag (localEvidenceSnapshotSignatureDomain) followed by +// the serialized LocalEvidenceSnapshotBody. The domain tag is a fixed constant +// prepended by both signer and verifier and is NOT carried on the wire - it +// domain-separates this signature from the node's other signed bodies (see the +// package comment). The body half is the bytes that travel: marshaled once for +// a self-authored snapshot, or the received body verbatim for a parsed one +// (verify exactly what was received). Evidence fields must not be mutated +// afterwards, and the returned slice is the internal cache - callers must not +// mutate it. +func (s *LocalEvidenceSnapshot) SignableBytes() ([]byte, error) { + if s == nil { + return nil, errors.New("roast: cannot encode a nil snapshot") + } + if s.signaturePayloadCache != nil { + return s.signaturePayloadCache, nil + } + body, err := s.bodyBytes() + if err != nil { + return nil, err + } + payload := make([]byte, 0, len(localEvidenceSnapshotSignatureDomain)+len(body)) + payload = append(payload, localEvidenceSnapshotSignatureDomain...) + payload = append(payload, body...) + s.signaturePayloadCache = payload + return payload, nil +} + +// wireEnvelopeBytes returns the exact on-wire SignedLocalEvidenceSnapshot +// envelope. For parsed snapshots this is the received envelope verbatim; +// for self-authored snapshots it is built once (after signing) and cached, +// so the broadcast bytes and the bytes embedded into a coordinator bundle +// are identical. +func (s *LocalEvidenceSnapshot) wireEnvelopeBytes() ([]byte, error) { + if s.wireEnvelope != nil { + return s.wireEnvelope, nil + } + if len(s.OperatorSignature) == 0 { + return nil, errors.New( + "roast: snapshot must be signed before wire encoding", + ) + } + body, err := s.bodyBytes() + if err != nil { + return nil, err + } + envelope, err := proto.Marshal(&pb.SignedLocalEvidenceSnapshot{ + Body: body, + OperatorSignature: s.OperatorSignature, + }) + if err != nil { + return nil, fmt.Errorf("roast: marshal snapshot envelope: %w", err) + } + s.wireEnvelope = envelope + return envelope, nil +} + +// bodyBytes returns the exact serialized TransitionMessageBody, which embeds +// every snapshot's signed envelope verbatim - the body carried in the +// SignedTransitionMessage envelope. Built and cached once for a self-authored +// message; the received bytes verbatim for a parsed one. The returned slice is +// the internal cache - callers must not mutate it. +func (m *TransitionMessage) bodyBytes() ([]byte, error) { + if m == nil { + return nil, errors.New("roast: cannot encode a nil transition message") + } + if m.bodyCache != nil { + return m.bodyCache, nil + } + body := &pb.TransitionMessageBody{ + AttemptContextHash: m.AttemptContextHash, + CoordinatorId: m.CoordinatorIDValue, + } + for i := range m.Bundle { + envelope, err := m.Bundle[i].wireEnvelopeBytes() + if err != nil { + return nil, fmt.Errorf("roast: bundle[%d]: %w", i, err) + } + body.SignedSnapshots = append(body.SignedSnapshots, envelope) + } + bodyBytes, err := proto.Marshal(body) + if err != nil { + return nil, fmt.Errorf("roast: marshal transition body: %w", err) + } + m.bodyCache = bodyBytes + return bodyBytes, nil +} + +// SignableBytes returns the exact byte stream the CoordinatorSignature covers: +// the transition domain tag (transitionMessageSignatureDomain) followed by the +// serialized TransitionMessageBody. The domain tag is a fixed constant +// prepended by both signer and verifier and is NOT carried on the wire - it +// domain-separates the coordinator's bundle signature from its other signed +// bodies (see the package comment). The coordinator's signature attests that +// these specific signed snapshots were assembled in this specific order; for a +// message parsed off the wire the body half is the received bytes verbatim. +// The returned slice is the internal cache - callers must not mutate it. +func (m *TransitionMessage) SignableBytes() ([]byte, error) { + if m == nil { + return nil, errors.New("roast: cannot encode a nil transition message") + } + if m.signaturePayloadCache != nil { + return m.signaturePayloadCache, nil + } + body, err := m.bodyBytes() + if err != nil { + return nil, err + } + payload := make([]byte, 0, len(transitionMessageSignatureDomain)+len(body)) + payload = append(payload, transitionMessageSignatureDomain...) + payload = append(payload, body...) + m.signaturePayloadCache = payload + return payload, nil +} diff --git a/pkg/frost/roast/wire_test.go b/pkg/frost/roast/wire_test.go new file mode 100644 index 0000000000..d20eb879a2 --- /dev/null +++ b/pkg/frost/roast/wire_test.go @@ -0,0 +1,195 @@ +package roast + +import ( + "bytes" + "testing" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// The properties pinned here are the point of the signed-body envelope +// format: signatures verify over exactly the bytes received, those bytes +// travel verbatim through re-broadcast and bundle aggregation, and no step +// ever depends on a serializer's canonical form. + +func signedTestSnapshot(t *testing.T, sender group.MemberIndex) *LocalEvidenceSnapshot { + t.Helper() + signer := &fakeSigner{id: sender} + snap := NewLocalEvidenceSnapshot(sender, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 2, 3: 4}, + }) + payload, err := snap.SignableBytes() + if err != nil { + t.Fatalf("signable bytes: %v", err) + } + sig, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + snap.OperatorSignature = sig + return snap +} + +func TestSnapshotWire_ReceivedBytesPreservedVerbatim(t *testing.T) { + original := signedTestSnapshot(t, 7) + wire, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := verifySnapshotSignature(fakeVerifier{}, decoded); err != nil { + t.Fatalf("verify decoded: %v", err) + } + + rebroadcast, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(rebroadcast, wire) { + t.Fatal("re-marshal of a received snapshot must return the received bytes verbatim") + } + + producerBody, _ := original.SignableBytes() + receiverBody, _ := decoded.SignableBytes() + if !bytes.Equal(producerBody, receiverBody) { + t.Fatal("receiver must verify over exactly the bytes the producer signed") + } +} + +func TestSnapshotWire_NonCanonicalEnvelopeEncodingSurvives(t *testing.T) { + original := signedTestSnapshot(t, 7) + // The wire body is the bare serialized body (NOT the domain-tagged + // SignableBytes); the signature over SignableBytes still verifies against + // it after decode. + body, _ := original.bodyBytes() + + // Handcraft an envelope with the fields in REVERSE tag order + // (operator_signature before body) - a wire-legal but non-canonical + // encoding no Go marshaler would produce. Field 1 (body) and field 2 + // (operator_signature) are both length-delimited: tags 0x0a and 0x12. + var crafted []byte + crafted = append(crafted, 0x12, byte(len(original.OperatorSignature))) + crafted = append(crafted, original.OperatorSignature...) + crafted = append(crafted, 0x0a, byte(len(body))) + crafted = append(crafted, body...) + + // Sanity: protobuf accepts field-order-free encodings. + var check pb.SignedLocalEvidenceSnapshot + if err := proto.Unmarshal(crafted, &check); err != nil { + t.Fatalf("crafted envelope must be wire-legal: %v", err) + } + + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(crafted); err != nil { + t.Fatalf("unmarshal crafted: %v", err) + } + if err := verifySnapshotSignature(fakeVerifier{}, decoded); err != nil { + t.Fatalf("signature must verify over the embedded body bytes: %v", err) + } + + remarshaled, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(remarshaled, crafted) { + t.Fatal("re-marshal must preserve even a non-canonical received encoding verbatim") + } +} + +func TestBundleWire_EmbedsReceivedSnapshotEnvelopesVerbatim(t *testing.T) { + snapshotWire := make([][]byte, 0, 2) + bundle := make([]LocalEvidenceSnapshot, 0, 2) + for _, sender := range []group.MemberIndex{1, 2} { + wire, err := signedTestSnapshot(t, sender).Marshal() + if err != nil { + t.Fatalf("marshal snapshot: %v", err) + } + // The coordinator receives the snapshot off the wire. + var received LocalEvidenceSnapshot + if err := received.Unmarshal(wire); err != nil { + t.Fatalf("coordinator unmarshal: %v", err) + } + snapshotWire = append(snapshotWire, wire) + bundle = append(bundle, received) + } + + coordinator := &fakeSigner{id: 2} + msg := &TransitionMessage{ + AttemptContextHash: append([]byte{}, pinnedContextHash[:]...), + CoordinatorIDValue: 2, + Bundle: bundle, + } + payload, err := msg.SignableBytes() + if err != nil { + t.Fatalf("bundle signable bytes: %v", err) + } + for _, wire := range snapshotWire { + if !bytes.Contains(payload, wire) { + t.Fatal("bundle body must embed each received snapshot envelope verbatim") + } + } + sig, err := coordinator.Sign(payload) + if err != nil { + t.Fatalf("sign bundle: %v", err) + } + msg.CoordinatorSignature = sig + + bundleWire, err := msg.Marshal() + if err != nil { + t.Fatalf("marshal bundle: %v", err) + } + decoded := &TransitionMessage{} + if err := decoded.Unmarshal(bundleWire); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + if err := verifyBundleSignature(fakeVerifier{}, decoded, 2); err != nil { + t.Fatalf("verify bundle: %v", err) + } + for i := range decoded.Bundle { + if err := verifySnapshotSignature(fakeVerifier{}, &decoded.Bundle[i]); err != nil { + t.Fatalf("verify embedded snapshot %d: %v", i, err) + } + } + + rebroadcast, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal bundle: %v", err) + } + if !bytes.Equal(rebroadcast, bundleWire) { + t.Fatal("re-marshal of a received bundle must return the received bytes verbatim") + } +} + +func TestSnapshotWire_TamperedBodyFailsVerification(t *testing.T) { + original := signedTestSnapshot(t, 7) + wire, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // Flip one byte inside the embedded body (the attempt context hash + // content sits well inside the envelope; the envelope structure stays + // parseable because only a value byte changes). + tampered := append([]byte(nil), wire...) + idx := bytes.Index(tampered, pinnedContextHash[:]) + if idx < 0 { + t.Fatal("context hash not found in wire bytes") + } + tampered[idx] ^= 0xff + + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(tampered); err != nil { + t.Fatalf("tampered envelope still parses (only a value changed): %v", err) + } + if err := verifySnapshotSignature(fakeVerifier{}, decoded); err == nil { + t.Fatal("signature over tampered body bytes must fail verification") + } +} diff --git a/pkg/frost/signing/attempt.go b/pkg/frost/signing/attempt.go new file mode 100644 index 0000000000..cc963d5f01 --- /dev/null +++ b/pkg/frost/signing/attempt.go @@ -0,0 +1,57 @@ +package signing + +import "github.com/keep-network/keep-core/pkg/protocol/group" + +// Attempt describes runtime context for a signing attempt coordinated by ROAST. +type Attempt struct { + // Number is the 1-based attempt number for the same message. Under active + // ROAST retry this is the COMMITTED roast attempt number (roastAttemptNumber+1), + // which can be strictly less than the wall-clock attempt count after + // pre-selection skips; without ROAST it is the block-paced attempt counter. + // BuildAttemptContextFromRequest maps it to the 0-based AttemptContext number. + Number uint + // CoordinatorMemberIndex is the member coordinating this attempt. + CoordinatorMemberIndex group.MemberIndex + // IncludedMembersIndexes are members participating in this attempt. + IncludedMembersIndexes []group.MemberIndex + // ExcludedMembersIndexes are members NOT participating in this attempt -- + // the permanently-excluded members together with the transiently-parked + // ones (TransientlyParkedMembersIndexes is a subset). + ExcludedMembersIndexes []group.MemberIndex + // TransientlyParkedMembersIndexes are the members the prior ROAST transition + // parked for THIS attempt only (silence/overflow): they do not participate + // now but the attempt after this one reinstates them. RFC-21 Phase 7.3 + // PR2b-1b carries this so a one-attempt park does not become permanent. + // Empty for the legacy/attempt-zero shape. + TransientlyParkedMembersIndexes []group.MemberIndex +} + +func cloneAttempt(attempt *Attempt) *Attempt { + if attempt == nil { + return nil + } + + // Preserve nil-ness for the optional parked set (nil when there is no + // parking) so a clone compares equal to an attempt that never set it. + var transientlyParked []group.MemberIndex + if attempt.TransientlyParkedMembersIndexes != nil { + transientlyParked = append( + []group.MemberIndex{}, + attempt.TransientlyParkedMembersIndexes..., + ) + } + + return &Attempt{ + Number: attempt.Number, + CoordinatorMemberIndex: attempt.CoordinatorMemberIndex, + IncludedMembersIndexes: append( + []group.MemberIndex{}, + attempt.IncludedMembersIndexes..., + ), + ExcludedMembersIndexes: append( + []group.MemberIndex{}, + attempt.ExcludedMembersIndexes..., + ), + TransientlyParkedMembersIndexes: transientlyParked, + } +} diff --git a/pkg/frost/signing/attempt_context_binding.go b/pkg/frost/signing/attempt_context_binding.go new file mode 100644 index 0000000000..5bb01b2cb9 --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding.go @@ -0,0 +1,72 @@ +//go:build frost_native + +package signing + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// AttemptContextHashFieldLength is the on-wire byte length of the +// optional AttemptContextHash field carried by the FROST/tbtc-signer +// protocol messages. The field is the canonical SHA-256 hash of the +// AttemptContext (see pkg/frost/roast/attempt), so 32 bytes. +const AttemptContextHashFieldLength = attempt.MessageDigestLength + +// validateAttemptContextHashField checks the length invariant for the +// optional AttemptContextHash field on protocol messages. An absent +// field (nil or zero-length slice) is valid; a present field must +// match AttemptContextHashFieldLength exactly. +// +// This is the only validation Phase 1B performs on the field. Higher- +// level acceptance (the receiver-side check that the hash matches the +// locally-computed AttemptContext) lands in a later RFC-21 phase +// behind a build tag, since enabling it requires honest peers to have +// rolled out the new field first. +func validateAttemptContextHashField(field []byte) error { + if len(field) == 0 { + return nil + } + if len(field) != AttemptContextHashFieldLength { + return fmt.Errorf( + "attempt context hash field has wrong length [%d], expected [%d] or absent", + len(field), + AttemptContextHashFieldLength, + ) + } + return nil +} + +// attemptContextHashFieldFromArray converts a fixed-size 32-byte hash +// into the slice form used on the wire. Returns a fresh slice so the +// caller's array cannot be mutated through the returned reference. +func attemptContextHashFieldFromArray( + hash [AttemptContextHashFieldLength]byte, +) []byte { + out := make([]byte, AttemptContextHashFieldLength) + copy(out, hash[:]) + return out +} + +// attemptContextHashFieldToArray converts a wire-form slice back to +// a fixed-size 32-byte hash plus a presence flag. Returns +// (zeroArray, false) when the field is absent. Caller has already +// validated length via validateAttemptContextHashField; this function +// trusts that invariant and panics on violation. +func attemptContextHashFieldToArray( + field []byte, +) ([AttemptContextHashFieldLength]byte, bool) { + var out [AttemptContextHashFieldLength]byte + if len(field) == 0 { + return out, false + } + if len(field) != AttemptContextHashFieldLength { + panic(fmt.Sprintf( + "attemptContextHashFieldToArray called with wrong-length field [%d]", + len(field), + )) + } + copy(out[:], field) + return out, true +} diff --git a/pkg/frost/signing/attempt_context_binding_test.go b/pkg/frost/signing/attempt_context_binding_test.go new file mode 100644 index 0000000000..915a5dca2b --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding_test.go @@ -0,0 +1,208 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +var pinnedAttemptContextHash = [AttemptContextHashFieldLength]byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, +} + +func TestValidateAttemptContextHashField_AcceptsAbsentOrCorrectLength(t *testing.T) { + tests := []struct { + name string + input []byte + }{ + {name: "nil is absent", input: nil}, + {name: "empty slice is absent", input: []byte{}}, + { + name: "exact length is accepted", + input: pinnedAttemptContextHash[:], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateAttemptContextHashField(tt.input); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateAttemptContextHashField_RejectsWrongLength(t *testing.T) { + tests := []struct { + name string + length int + }{ + {name: "too short", length: 31}, + {name: "too long", length: 33}, + {name: "one byte", length: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAttemptContextHashField( + bytes.Repeat([]byte{0xff}, tt.length), + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "wrong length") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestAttemptContextHashField_ArrayRoundTrip(t *testing.T) { + field := attemptContextHashFieldFromArray(pinnedAttemptContextHash) + if len(field) != AttemptContextHashFieldLength { + t.Fatalf( + "expected length %d, got %d", + AttemptContextHashFieldLength, len(field), + ) + } + got, present := attemptContextHashFieldToArray(field) + if !present { + t.Fatal("expected presence=true") + } + if got != pinnedAttemptContextHash { + t.Fatalf("array round-trip mismatch: got %x want %x", got, pinnedAttemptContextHash) + } +} + +func TestAttemptContextHashField_ArrayToArrayAbsent(t *testing.T) { + got, present := attemptContextHashFieldToArray(nil) + if present { + t.Fatal("expected presence=false for nil") + } + var zero [AttemptContextHashFieldLength]byte + if got != zero { + t.Fatalf("expected zero array, got %x", got) + } +} + +func TestAttemptContextHashField_FromArrayDoesNotAliasCaller(t *testing.T) { + arr := pinnedAttemptContextHash + field := attemptContextHashFieldFromArray(arr) + field[0] = 0xff + if arr[0] == 0xff { + t.Fatal("mutation through returned slice modified caller's array") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_OptionalFieldRoundTrip(t *testing.T) { + withHash := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: 3, + SessionIDValue: "session-3", + ContributionIdentifier: 1, + ContributionData: []byte{0xee, 0xff}, + } + withHash.SetAttemptContextHash(pinnedAttemptContextHash) + data, err := withHash.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + decoded := &buildTaggedTBTCSignerRoundContributionMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + got, present := decoded.GetAttemptContextHash() + if !present || got != pinnedAttemptContextHash { + t.Fatalf("round-trip lost hash: present=%v got=%x", present, got) + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_BackwardCompatWithOldJSON(t *testing.T) { + oldJSON := []byte(`{ + "senderID":3, + "sessionID":"session-3", + "contributionIdentifier":1, + "contributionData":"qrs=" + }`) + + decoded := &buildTaggedTBTCSignerRoundContributionMessage{} + if err := decoded.Unmarshal(oldJSON); err != nil { + t.Fatalf("unmarshal of old-format JSON failed: %v", err) + } + if _, present := decoded.GetAttemptContextHash(); present { + t.Fatal("expected absent hash for old-format JSON") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_RejectsWrongLengthHashField(t *testing.T) { + badJSON := []byte(`{ + "senderID":3, + "sessionID":"session-3", + "contributionIdentifier":1, + "contributionData":"qrs=", + "attemptContextHash":"AAEC" + }`) + + decoded := &buildTaggedTBTCSignerRoundContributionMessage{} + err := decoded.Unmarshal(badJSON) + if err == nil { + t.Fatal("expected wrong-length validation error") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessagesEqual_HashFieldDifferentiates(t *testing.T) { + base := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: 1, + SessionIDValue: "session-1", + ContributionIdentifier: 1, + ContributionData: []byte{0xaa}, + } + withHashA := *base + withHashA.SetAttemptContextHash(pinnedAttemptContextHash) + + otherHash := pinnedAttemptContextHash + otherHash[0] ^= 0xff + withHashB := *base + withHashB.SetAttemptContextHash(otherHash) + + if buildTaggedTBTCSignerRoundContributionMessagesEqual(base, &withHashA) { + t.Fatal("base (no hash) vs with-hash must compare unequal") + } + if buildTaggedTBTCSignerRoundContributionMessagesEqual(&withHashA, &withHashB) { + t.Fatal("messages with different hashes must compare unequal") + } + withHashAClone := *base + withHashAClone.SetAttemptContextHash(pinnedAttemptContextHash) + if !buildTaggedTBTCSignerRoundContributionMessagesEqual(&withHashA, &withHashAClone) { + t.Fatal("messages with the same hash must compare equal") + } + if !buildTaggedTBTCSignerRoundContributionMessagesEqual(base, base) { + t.Fatal("identical-pointer comparison must be equal") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_JSONEncoderOmitsAbsentField(t *testing.T) { + original := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: 1, + SessionIDValue: "s", + ContributionIdentifier: 1, + ContributionData: []byte{0xaa}, + } + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("re-decode failed: %v", err) + } + if _, ok := raw["attemptContextHash"]; ok { + t.Fatalf( + "omitempty did not suppress absent attemptContextHash; raw=%v", + raw, + ) + } +} diff --git a/pkg/frost/signing/attempt_context_binding_validation_default_build_test.go b/pkg/frost/signing/attempt_context_binding_validation_default_build_test.go new file mode 100644 index 0000000000..3d166291a7 --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding_validation_default_build_test.go @@ -0,0 +1,34 @@ +//go:build frost_native && !frost_roast_retry + +package signing + +import ( + "testing" +) + +func TestVerifyMessageAttemptContextHash_DefaultBuildPassesEverything(t *testing.T) { + // Without the frost_roast_retry tag, currentAttemptHandleForCollect + // always returns ok=false, so the helper short-circuits to nil + // for every input. This guarantees that the receive-loop wiring + // never enforces the AttemptContextHash binding in the default + // build, matching the rollback promise made in the rollout + // guide (docs/development/frost-roast-retry-rollout.adoc). + msg := stubDefaultBuildMessage{} + if err := verifyMessageAttemptContextHash(msg, "any-session", 1); err != nil { + t.Fatalf( + "default build must always pass; got %v", + err, + ) + } +} + +// stubDefaultBuildMessage is the equivalent of the tagged-build +// test's stubMessage. Kept separate to avoid the tagged-build +// definition leaking into this build's compilation unit. +type stubDefaultBuildMessage struct{} + +func (stubDefaultBuildMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return [AttemptContextHashFieldLength]byte{}, false +} diff --git a/pkg/frost/signing/attempt_context_binding_validation_frost_native.go b/pkg/frost/signing/attempt_context_binding_validation_frost_native.go new file mode 100644 index 0000000000..c213e8801d --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding_validation_frost_native.go @@ -0,0 +1,118 @@ +//go:build frost_native + +package signing + +import ( + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// attemptContextHashCarrier is implemented by every protocol +// message type that carries the optional AttemptContextHash field +// introduced in RFC-21 Phase 1B. The validation helper below uses +// the interface so a single implementation covers all three +// FROST/tbtc-signer message types without duplicating per-type +// logic. +type attemptContextHashCarrier interface { + // GetAttemptContextHash returns the message's hash and a + // presence flag. Implementations are generated by the per-type + // Set/Get helpers in attempt_context_binding.go. + GetAttemptContextHash() ([AttemptContextHashFieldLength]byte, bool) +} + +// outboundAttemptContextHashCarrier is implemented by every protocol +// message type that can carry the optional AttemptContextHash field +// on outbound sends. +type outboundAttemptContextHashCarrier interface { + SetAttemptContextHash([AttemptContextHashFieldLength]byte) +} + +// ErrAttemptContextHashMissing is returned when a message lacks +// the AttemptContextHash field while the session is bound to a +// ROAST attempt that requires it. Distinct sentinel so callers +// can map it to a specific RecordReject reason. +var ErrAttemptContextHashMissing = errors.New( + "attempt context hash required: session is ROAST-active but message omits the binding field", +) + +// ErrAttemptContextHashMismatch is returned when a message's +// AttemptContextHash does not match the session's currently-bound +// AttemptContext.Hash(). The peer is either talking about a stale +// attempt (post-transition) or trying to inject a message for a +// different context. +var ErrAttemptContextHashMismatch = errors.New( + "attempt context hash mismatch: message bound to a different attempt", +) + +// verifyMessageAttemptContextHash enforces the RFC-21 Phase-6 +// milestone that promotes the AttemptContextHash field from +// optional to required at the receive boundary, but only when the +// session has a ROAST-attempt binding registered. +// +// When no session-handle binding exists for (sessionID, member) (the +// typical state for non-ROAST sessions and for default builds), this +// function returns nil and lets the message through. The receive +// loop's other gates (shouldAcceptNativeFROSTMessage, etc.) still +// apply. +// +// member is the LOCAL receiver seat's member index (request.MemberIndex), +// NOT the inbound message's sender: the binding being enforced is the +// one THIS seat's orchestration set for the attempt it is running. A +// multi-seat operator keys its bindings per seat (RFC-21 Phase 7.3 +// PR2b-2), so looking up by sender would read the wrong (or no) binding. +// +// When a binding exists -- i.e. the orchestration layer has begun +// an attempt for this session and is expecting the receive loops +// to participate -- the message must carry an AttemptContextHash +// that equals the bound context's Hash(). Returns +// ErrAttemptContextHashMissing or ErrAttemptContextHashMismatch on +// failure so the caller can RecordReject with a precise reason. +func verifyMessageAttemptContextHash( + msg attemptContextHashCarrier, + sessionID string, + member group.MemberIndex, +) error { + _, ctx, ok := currentAttemptHandleForCollect(sessionID, member) + if !ok { + // No binding: legacy / non-ROAST mode. Skip enforcement + // so default builds and non-ROAST sessions stay + // observationally identical to pre-Phase-6 behaviour. + return nil + } + msgHash, present := msg.GetAttemptContextHash() + if !present { + return ErrAttemptContextHashMissing + } + expected := ctx.Hash() + if msgHash != expected { + return fmt.Errorf( + "%w: message=%x, current attempt=%x", + ErrAttemptContextHashMismatch, + msgHash[:4], + expected[:4], + ) + } + return nil +} + +// setMessageAttemptContextHashIfBound attaches the current ROAST +// attempt binding to an outbound message. Default/non-ROAST sessions +// have no binding, so the field stays absent for backward +// compatibility. member is the local sender seat's member index +// (request.MemberIndex); the binding is looked up per (sessionID, +// member) so a multi-seat operator tags each seat's outbound message +// with that seat's own bound context (RFC-21 Phase 7.3 PR2b-2). +func setMessageAttemptContextHashIfBound( + msg outboundAttemptContextHashCarrier, + sessionID string, + member group.MemberIndex, +) { + _, ctx, ok := currentAttemptHandleForCollect(sessionID, member) + if !ok { + return + } + + msg.SetAttemptContextHash(ctx.Hash()) +} diff --git a/pkg/frost/signing/attempt_context_binding_validation_frost_native_test.go b/pkg/frost/signing/attempt_context_binding_validation_frost_native_test.go new file mode 100644 index 0000000000..3868762576 --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding_validation_frost_native_test.go @@ -0,0 +1,258 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// stubMessage is a minimal attemptContextHashCarrier implementation +// for unit tests. The receive callbacks use the three real message +// types; the helper itself is exercised via this stub so the test +// surface stays small. +type stubMessage struct { + hash [AttemptContextHashFieldLength]byte + present bool +} + +func (s stubMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return s.hash, s.present +} + +func (s *stubMessage) SetAttemptContextHash( + hash [AttemptContextHashFieldLength]byte, +) { + s.hash = hash + s.present = true +} + +func newOrchestrationTestContextForValidation(t *testing.T) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "validation-test", + "key-group", + []byte{0x01, 0x02}, + [attempt.MessageDigestLength]byte{0x77}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + return ctx +} + +func TestVerifyMessageAttemptContextHash_NoBindingPasses(t *testing.T) { + // In the default build, no session-handle bindings exist so + // every call returns nil regardless of message contents. The + // receive loop's other gates still apply. + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + cases := []stubMessage{ + {present: false}, + {present: true, hash: [AttemptContextHashFieldLength]byte{0x01}}, + } + for _, msg := range cases { + if err := verifyMessageAttemptContextHash(msg, "session-x", 1); err != nil { + t.Fatalf( + "no-binding path must pass; got %v for msg %+v", + err, msg, + ) + } + } +} + +func TestVerifyMessageAttemptContextHash_BindingPresent_MatchingHashPasses(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-match", 1, roast.AttemptHandle{}, ctx) + + expected := ctx.Hash() + msg := stubMessage{hash: expected, present: true} + if err := verifyMessageAttemptContextHash(msg, "session-match", 1); err != nil { + t.Fatalf("matching hash must pass; got %v", err) + } +} + +func TestVerifyMessageAttemptContextHash_BindingPresent_MissingHashFails(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-missing", 1, roast.AttemptHandle{}, ctx) + + msg := stubMessage{present: false} + err := verifyMessageAttemptContextHash(msg, "session-missing", 1) + if !errors.Is(err, ErrAttemptContextHashMissing) { + t.Fatalf( + "expected ErrAttemptContextHashMissing; got %v", + err, + ) + } +} + +func TestVerifyMessageAttemptContextHash_BindingPresent_MismatchedHashFails(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-mismatch", 1, roast.AttemptHandle{}, ctx) + + wrong := [AttemptContextHashFieldLength]byte{} + for i := range wrong { + wrong[i] = 0xff + } + msg := stubMessage{hash: wrong, present: true} + err := verifyMessageAttemptContextHash(msg, "session-mismatch", 1) + if !errors.Is(err, ErrAttemptContextHashMismatch) { + t.Fatalf( + "expected ErrAttemptContextHashMismatch; got %v", + err, + ) + } +} + +// TestVerifyMessageAttemptContextHash_BindingIsMemberScoped asserts the binding +// lookup is keyed by the LOCAL receiver seat's member (request.MemberIndex), not +// shared across seats: a binding set for member 1 enforces the hash for member 1 +// but is invisible to member 2's receive loop (which has its own binding or, here, +// none -> passes through). This is the PR2b-2 member-keying applied to the receive +// validation path; under the old sessionID-only key, member 2 would have enforced +// member 1's binding. +func TestVerifyMessageAttemptContextHash_BindingIsMemberScoped(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-scoped", 1, roast.AttemptHandle{}, ctx) + + // A message that does NOT match the bound context. + wrong := [AttemptContextHashFieldLength]byte{} + for i := range wrong { + wrong[i] = 0xff + } + msg := stubMessage{hash: wrong, present: true} + + // Member 1 has the binding -> enforcement runs -> mismatch. + if err := verifyMessageAttemptContextHash(msg, "session-scoped", 1); !errors.Is(err, ErrAttemptContextHashMismatch) { + t.Fatalf("member 1 must enforce its binding; got %v", err) + } + // Member 2 has no binding for this session -> passes through (no enforcement). + if err := verifyMessageAttemptContextHash(msg, "session-scoped", 2); err != nil { + t.Fatalf("member 2 (no binding) must pass through; got %v", err) + } +} + +func TestVerifyMessageAttemptContextHash_RealMessageTypeIntegration(t *testing.T) { + // Exercise the helper against a real protocol message type + // (the tbtc-signer round contribution) rather than just the stub, + // so the test surface covers the actual Set/Get + // helpers code path. + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-real-msg", 1, roast.AttemptHandle{}, ctx) + + expected := ctx.Hash() + msg := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: 1, + SessionIDValue: "session-real-msg", + ContributionIdentifier: 1, + ContributionData: []byte{0x01}, + } + msg.SetAttemptContextHash(expected) + + if err := verifyMessageAttemptContextHash(msg, "session-real-msg", 1); err != nil { + t.Fatalf("real-message integration must pass; got %v", err) + } + + // Now mutate the context to break the binding. + differentCtx, _ := attempt.NewAttemptContext( + "session-real-msg", + "key-group", + []byte{0x99}, + [attempt.MessageDigestLength]byte{0x77}, + 1, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + SetCurrentAttemptHandleForSession("session-real-msg", 1, roast.AttemptHandle{}, differentCtx) + + err := verifyMessageAttemptContextHash(msg, "session-real-msg", 1) + if !errors.Is(err, ErrAttemptContextHashMismatch) { + t.Fatalf("rebinding must cause mismatch; got %v", err) + } +} + +func TestSetMessageAttemptContextHashIfBound_AttachesBoundHash(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-outbound", 1, roast.AttemptHandle{}, ctx) + + msg := &stubMessage{} + setMessageAttemptContextHashIfBound(msg, "session-outbound", 1) + + got, present := msg.GetAttemptContextHash() + if !present { + t.Fatal("expected outbound message to carry attempt context hash") + } + if got != ctx.Hash() { + t.Fatalf("unexpected attempt context hash: got %x want %x", got, ctx.Hash()) + } +} + +func TestSetMessageAttemptContextHashIfBound_NoBindingLeavesAbsent(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + msg := &stubMessage{} + setMessageAttemptContextHashIfBound(msg, "session-no-binding", 1) + + if _, present := msg.GetAttemptContextHash(); present { + t.Fatal("expected no attempt context hash without a session binding") + } +} + +func TestSetMessageAttemptContextHashIfBound_AllOutboundMessageTypes(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-all-types", 1, roast.AttemptHandle{}, ctx) + expected := ctx.Hash() + + messages := []attemptContextHashCarrier{ + &buildTaggedTBTCSignerRoundContributionMessage{}, + } + + for _, msg := range messages { + outbound, ok := msg.(outboundAttemptContextHashCarrier) + if !ok { + t.Fatalf("%T does not implement outbound carrier", msg) + } + + setMessageAttemptContextHashIfBound(outbound, "session-all-types", 1) + + got, present := msg.GetAttemptContextHash() + if !present { + t.Fatalf("%T did not get attempt context hash", msg) + } + if got != expected { + t.Fatalf("%T hash mismatch: got %x want %x", msg, got, expected) + } + } +} diff --git a/pkg/frost/signing/attempt_context_bound_exchange_frost_native_test.go b/pkg/frost/signing/attempt_context_bound_exchange_frost_native_test.go new file mode 100644 index 0000000000..6436cb6623 --- /dev/null +++ b/pkg/frost/signing/attempt_context_bound_exchange_frost_native_test.go @@ -0,0 +1,162 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "context" + "math/big" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func bindAttemptContextHashForExchangeTest( + t *testing.T, + sessionID string, + members []group.MemberIndex, +) { + t.Helper() + + var messageDigest [attempt.MessageDigestLength]byte + copy(messageDigest[:], []byte("bound-attempt-context-exchange")) + + ctx, err := attempt.NewAttemptContext( + sessionID, + "key-group", + []byte{0x01, 0x02, 0x03}, + messageDigest, + 0, + members, + nil, + ) + if err != nil { + t.Fatalf("failed creating attempt context: [%v]", err) + } + + // Bind the (identical, group-level) attempt context for EVERY participating + // local member seat. The bootstrap round runs one receive loop per member, and + // each loop looks the binding up by its own (sessionID, member) after PR2b-2 + // member-keyed the registry; binding only one member would leave the others' + // loops unbound and silently skip the hash enforcement this test exercises. + for _, member := range members { + SetCurrentAttemptHandleForSession(sessionID, member, roast.AttemptHandle{}, ctx) + } +} + +func TestBuildTaggedTBTCSignerBootstrapCoarseRound_BoundAttemptContextHashExchange( + t *testing.T, +) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + provider := local.Connect() + channel, err := provider.BroadcastChannelFor( + "tbtc-signer-bootstrap-bound-attempt-context-test", + ) + if err != nil { + t.Fatalf("failed creating broadcast channel: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + primitive.RegisterUnmarshallers(channel) + + sessionID := "tbtc-signer-bound-attempt-context" + includedMembers := []group.MemberIndex{1, 2} + bindAttemptContextHashForExchangeTest(t, sessionID, includedMembers) + + engineByMember := map[group.MemberIndex]*deterministicBuildTaggedTBTCSignerBootstrapRoundEngine{ + 1: { + roundState: &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 1, + Data: []byte{0x11, 0x01}, + }, + }, + }, + 2: { + roundState: &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 2, + Data: []byte{0x22, 0x02}, + }, + }, + }, + } + + requestByMember := map[group.MemberIndex]*NativeExecutionFFISigningRequest{ + 1: { + Message: big.NewInt(123), + SessionID: sessionID, + MemberIndex: 1, + GroupSize: 2, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: includedMembers, + }, + }, + 2: { + Message: big.NewInt(123), + SessionID: sessionID, + MemberIndex: 2, + GroupSize: 2, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: includedMembers, + }, + }, + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCtx() + + signingErrors := make(chan error, len(requestByMember)) + var wg sync.WaitGroup + wg.Add(len(requestByMember)) + + for memberIndex, request := range requestByMember { + engine := engineByMember[memberIndex] + go func( + signingRequest *NativeExecutionFFISigningRequest, + signingEngine NativeTBTCSignerEngine, + ) { + defer wg.Done() + + signingErrors <- executeBuildTaggedTBTCSignerBootstrapCoarseRound( + ctx, + signingRequest, + "group-1", + signingEngine, + nil, + nil, + ) + }(request, engine) + } + + wg.Wait() + close(signingErrors) + + for signErr := range signingErrors { + if signErr != nil { + t.Fatalf("unexpected signing error: [%v]", signErr) + } + } +} diff --git a/pkg/frost/signing/attempt_context_from_request.go b/pkg/frost/signing/attempt_context_from_request.go new file mode 100644 index 0000000000..d722b2aa45 --- /dev/null +++ b/pkg/frost/signing/attempt_context_from_request.go @@ -0,0 +1,245 @@ +//go:build frost_native + +package signing + +import ( + "errors" + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ErrAttemptContextConstruction is the sentinel error class returned +// by BuildAttemptContextFromRequest for any failure during +// construction. Callers can match with errors.Is to distinguish +// it from runtime ROAST errors. +var ErrAttemptContextConstruction = errors.New( + "attempt context: construction failed", +) + +// BuildAttemptContextFromRequest converts a +// NativeExecutionFFISigningRequest into an attempt.AttemptContext +// suitable for Coordinator.BeginAttempt. The conversion: +// +// - SessionID, AttemptNumber, IncludedSet, ExcludedSet come from +// the request and its Attempt sub-struct directly. +// - TransientlyParked is empty: the existing Attempt struct does +// not carry parking info. Phase-7+ orchestration that drives +// multi-attempt sessions will need to thread parking metadata +// through; Phase 6 only handles attempt-zero shape. +// - MessageDigest is the request.Message bytes left-padded with +// zeros to 32 bytes, then truncated if longer. In BIP-340 +// production, request.Message is already a 32-byte digest of +// the tagged payload, so padding is a no-op. +// - DkgGroupPublicKey is extracted via +// ExtractDkgGroupPublicKeyFromMaterial. +// - KeyGroupID is derived from the raw FrostTBTCSignerV1 KeyGroup string +// identifier, which is already a canonical per-group handle. +// - AttemptSeed = SHA256(DkgGroupPublicKey || SessionID || +// MessageDigest) per RFC-21 Decision 2. +// +// Critically, the FFI signer material is decoded *first* so any +// extraction failure is surfaced before the AttemptContext is +// constructed. This enforces the ordering Gemini flagged in the +// Phase-6 design review: AttemptContext must never be built from +// undecoded material because the seed derivation would silently +// fail. +// +// Returns ErrAttemptContextConstruction-wrapped errors for any +// failure during the construction. Returns ErrUnsupportedSignerMaterialFormat +// (via errors.Is) when the material's format is not extractable +// (e.g. FrostUniFFIV1 or unsupported FrostUniFFIV2 today). +func BuildAttemptContextFromRequest( + request *NativeExecutionFFISigningRequest, +) (attempt.AttemptContext, error) { + if request == nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: request is nil", + ErrAttemptContextConstruction, + ) + } + if request.Message == nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: request message is nil", + ErrAttemptContextConstruction, + ) + } + if request.SignerMaterial == nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: signer material is nil", + ErrAttemptContextConstruction, + ) + } + if request.Attempt == nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: attempt metadata is nil", + ErrAttemptContextConstruction, + ) + } + + // Strict ordering: extract DKG group public key (which decodes + // the signer material) BEFORE deriving the context. A failure + // here propagates directly without leaving a half-built + // context. + dkgPub, err := ExtractDkgGroupPublicKeyFromMaterial(request.SignerMaterial) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %w", + ErrAttemptContextConstruction, + err, + ) + } + + keyGroupID, err := deriveKeyGroupID(request.SignerMaterial, dkgPub) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %w", + ErrAttemptContextConstruction, + err, + ) + } + + digest, err := messageDigestFromBigInt(request.Message) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %w", + ErrAttemptContextConstruction, + err, + ) + } + + // AttemptNumber on the keep-core Attempt struct is 1-based + // (1 = first attempt). RFC-21's AttemptContext.AttemptNumber is + // 0-based. Convert by subtracting 1 (Attempt.Number must be + // >= 1). + if request.Attempt.Number == 0 { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: request.Attempt.Number is zero (must be >= 1)", + ErrAttemptContextConstruction, + ) + } + attemptNumber := uint32(request.Attempt.Number - 1) + + // Prefer the STABLE ROAST session id so ctx.SessionID -- and everything + // keyed off it (the orchestration handle + transition-record registries, + // the selector lookup, the interactive engine session) -- is stable across + // attempts; the per-attempt SessionID would make the next attempt's selector + // unable to find the previous attempt's transition record. Fall back to + // SessionID when the caller does not drive ROAST orchestration. + roastSessionID := request.RoastSessionID + if roastSessionID == "" { + roastSessionID = request.SessionID + } + + // RFC-21 Phase 7.3 PR2b-1b: carry transient parking. Attempt.Excluded is the + // full "not participating now" set (permanent-excluded plus transiently- + // parked); split it so the AttemptContext distinguishes them, or NextAttempt + // would treat a one-attempt park as a permanent exclusion and never reinstate + // the member. + transientlyParked := request.Attempt.TransientlyParkedMembersIndexes + permanentExcluded := membersDifference( + request.Attempt.ExcludedMembersIndexes, + transientlyParked, + ) + ctx, err := attempt.NewAttemptContextWithParking( + roastSessionID, + keyGroupID, + dkgPub, + digest, + attemptNumber, + request.Attempt.IncludedMembersIndexes, + permanentExcluded, + transientlyParked, + ) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %w", + ErrAttemptContextConstruction, + err, + ) + } + return ctx, nil +} + +// membersDifference returns the members in `all` not present in `remove`, +// preserving the order of `all`. Used to split an attempt's "not participating" +// set into permanently-excluded vs transiently-parked. +func membersDifference(all, remove []group.MemberIndex) []group.MemberIndex { + if len(remove) == 0 { + // Return a fresh slice, never the caller's backing array: the result + // becomes permanentExcluded on the context-build path and must not alias + // request.Attempt.ExcludedMembersIndexes (shared via the request template). + return append([]group.MemberIndex(nil), all...) + } + removeSet := make(map[group.MemberIndex]bool, len(remove)) + for _, m := range remove { + removeSet[m] = true + } + out := make([]group.MemberIndex, 0, len(all)) + for _, m := range all { + if !removeSet[m] { + out = append(out, m) + } + } + return out +} + +// deriveKeyGroupID computes the AttemptContext KeyGroupID field +// from the signer material plus the already-extracted DKG group +// public key. The derivation is format-aware: +// +// - FrostTBTCSignerV1: the raw KeyGroup string from the tbtc- +// signer material. That string is the canonical handle. +// +// Returns an error for unknown formats; the caller will already +// have rejected unsupported formats via ExtractDkgGroupPublicKeyFromMaterial, +// so reaching the default arm here is an internal consistency +// error. +func deriveKeyGroupID( + signerMaterial *NativeSignerMaterial, + dkgPub []byte, +) (string, error) { + switch signerMaterial.Format { + case NativeSignerMaterialFormatFrostTBTCSignerV1: + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) + if err != nil { + return "", fmt.Errorf("derive key group id: %w", err) + } + return payload.KeyGroup, nil + default: + return "", fmt.Errorf( + "derive key group id: cannot derive id from format %q", + signerMaterial.Format, + ) + } +} + +// messageDigestFromBigInt converts a *big.Int message to the +// 32-byte digest shape AttemptContext expects. Big-int values +// shorter than 32 bytes are left-padded with zeros (big.Int.Bytes +// strips leading zeros). Values longer than 32 bytes return an +// error -- a real digest never exceeds 32 bytes for SHA-256. +func messageDigestFromBigInt( + message *big.Int, +) ([attempt.MessageDigestLength]byte, error) { + var out [attempt.MessageDigestLength]byte + if message == nil { + return out, fmt.Errorf("message is nil") + } + bz := message.Bytes() + if len(bz) > attempt.MessageDigestLength { + return out, fmt.Errorf( + "message digest length %d exceeds expected %d", + len(bz), + attempt.MessageDigestLength, + ) + } + // Left-pad with zeros: big.Int.Bytes strips leading zeros, so a + // 32-byte digest with a leading zero byte returns a 31-byte + // slice. Copy into the tail of `out` to restore canonical + // alignment. + copy(out[attempt.MessageDigestLength-len(bz):], bz) + return out, nil +} diff --git a/pkg/frost/signing/attempt_context_from_request_test.go b/pkg/frost/signing/attempt_context_from_request_test.go new file mode 100644 index 0000000000..2de38d0f6c --- /dev/null +++ b/pkg/frost/signing/attempt_context_from_request_test.go @@ -0,0 +1,249 @@ +//go:build frost_native + +package signing + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newTestRequestWithUnsupportedUniFFIV2Material(t *testing.T, attemptNumber uint) *NativeExecutionFFISigningRequest { + t.Helper() + const hexKey = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + payload := unsupportedUniFFIV2Payload(t, hexKey) + return &NativeExecutionFFISigningRequest{ + Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}), + SessionID: "session-test", + MemberIndex: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + }, + Attempt: &Attempt{ + Number: attemptNumber, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 4, 5}, + ExcludedMembersIndexes: nil, + }, + } +} + +func newTestRequestWithTBTCSignerV1Material(t *testing.T, attemptNumber uint) *NativeExecutionFFISigningRequest { + t.Helper() + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "tbtc-group-A", + }) + return &NativeExecutionFFISigningRequest{ + Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}), + SessionID: "session-test", + MemberIndex: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, + Attempt: &Attempt{ + Number: attemptNumber, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3}, + ExcludedMembersIndexes: nil, + }, + } +} + +func TestBuildAttemptContextFromRequest_TBTCSignerV1_KeyGroupIDIsRawIdentifier(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 1) + ctx, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctx.KeyGroupID != "tbtc-group-A" { + t.Fatalf( + "key group id: got %q, want %q", + ctx.KeyGroupID, + "tbtc-group-A", + ) + } +} + +func TestBuildAttemptContextFromRequest_UnsupportedUniFFIV2Rejected(t *testing.T) { + req := newTestRequestWithUnsupportedUniFFIV2Material(t, 1) + _, err := BuildAttemptContextFromRequest(req) + if !errors.Is(err, ErrUnsupportedSignerMaterialFormat) { + t.Fatalf("expected ErrUnsupportedSignerMaterialFormat, got %v", err) + } + if !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("error should mention unsupported format; got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_RejectsNilRequest(t *testing.T) { + _, err := BuildAttemptContextFromRequest(nil) + if !errors.Is(err, ErrAttemptContextConstruction) { + t.Fatalf("expected ErrAttemptContextConstruction, got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_RejectsNilMessage(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 1) + req.Message = nil + _, err := BuildAttemptContextFromRequest(req) + if err == nil { + t.Fatal("expected error for nil message") + } + if !strings.Contains(err.Error(), "message is nil") { + t.Fatalf("error must mention nil message; got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_RejectsNilSignerMaterial(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 1) + req.SignerMaterial = nil + _, err := BuildAttemptContextFromRequest(req) + if err == nil { + t.Fatal("expected error for nil signer material") + } + if !strings.Contains(err.Error(), "signer material is nil") { + t.Fatalf("error must mention nil signer material; got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_RejectsNilAttempt(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 1) + req.Attempt = nil + _, err := BuildAttemptContextFromRequest(req) + if err == nil { + t.Fatal("expected error for nil attempt metadata") + } +} + +func TestBuildAttemptContextFromRequest_RejectsZeroAttemptNumber(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 0) + _, err := BuildAttemptContextFromRequest(req) + if err == nil { + t.Fatal("expected error for zero attempt number") + } + if !strings.Contains(err.Error(), "Attempt.Number is zero") { + t.Fatalf("error must mention zero attempt; got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_PropagatesExtractionErrors(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 1) + req.SignerMaterial = &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte("{}"), + } + _, err := BuildAttemptContextFromRequest(req) + if !errors.Is(err, ErrUnsupportedSignerMaterialFormat) { + t.Fatalf("expected ErrUnsupportedSignerMaterialFormat, got %v", err) + } + if !errors.Is(err, ErrAttemptContextConstruction) { + t.Fatalf("expected ErrAttemptContextConstruction wrapper, got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_AttemptNumberIsZeroBased(t *testing.T) { + cases := []struct { + legacyNumber uint + expectedZeroBased uint32 + }{ + {1, 0}, + {2, 1}, + {5, 4}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("legacy=%d", tc.legacyNumber), func(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, tc.legacyNumber) + ctx, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctx.AttemptNumber != tc.expectedZeroBased { + t.Fatalf( + "got attempt number %d, want %d (legacy 1-based input %d)", + ctx.AttemptNumber, tc.expectedZeroBased, tc.legacyNumber, + ) + } + }) + } +} + +func TestMessageDigestFromBigInt_PadsShortBigInts(t *testing.T) { + bi := new(big.Int).SetBytes([]byte{0x01, 0x02}) + digest, err := messageDigestFromBigInt(bi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := [attempt.MessageDigestLength]byte{} + want[30] = 0x01 + want[31] = 0x02 + if digest != want { + t.Fatalf("padding wrong: got %x, want %x", digest, want) + } +} + +func TestMessageDigestFromBigInt_RejectsLongBigInts(t *testing.T) { + bi := new(big.Int).SetBytes(make([]byte, 33)) + bi.SetBit(bi, 264, 1) // 33-byte length + _, err := messageDigestFromBigInt(bi) + if err == nil { + t.Fatal("expected error for over-long message") + } +} + +func TestBuildAttemptContextFromRequest_DeterministicAcrossInvocations(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 1) + a, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("first: %v", err) + } + b, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("second: %v", err) + } + if a.Hash() != b.Hash() { + t.Fatalf( + "two calls with same request produced different hashes: %x vs %x", + a.Hash(), b.Hash(), + ) + } +} + +func TestBuildAttemptContextFromRequest_HashChangesWhenMessageDigestChanges(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 1) + a, _ := BuildAttemptContextFromRequest(req) + req.Message = new(big.Int).SetBytes([]byte{0x99, 0x88, 0x77}) + b, _ := BuildAttemptContextFromRequest(req) + if a.Hash() == b.Hash() { + t.Fatal("hash must change when message digest changes") + } +} + +func TestBuildAttemptContextFromRequest_HashChangesWhenIncludedSetChanges(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 1) + a, _ := BuildAttemptContextFromRequest(req) + req.Attempt.IncludedMembersIndexes = []group.MemberIndex{1, 2, 4} + b, _ := BuildAttemptContextFromRequest(req) + if a.Hash() == b.Hash() { + t.Fatal("hash must change when included set changes") + } +} + +// Sanity check that the message digest padding produces the same +// bytes as a direct SHA-256 (just a smoke test on the constants). +func TestMessageDigestFromBigInt_SmokeTestSha256Length(t *testing.T) { + if attempt.MessageDigestLength != sha256.Size { + t.Fatalf( + "AttemptContext digest length %d != SHA-256 size %d", + attempt.MessageDigestLength, sha256.Size, + ) + } +} diff --git a/pkg/frost/signing/attempt_test.go b/pkg/frost/signing/attempt_test.go new file mode 100644 index 0000000000..8d8f87fbc2 --- /dev/null +++ b/pkg/frost/signing/attempt_test.go @@ -0,0 +1,41 @@ +package signing + +import ( + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestCloneAttempt(t *testing.T) { + original := &Attempt{ + Number: 3, + CoordinatorMemberIndex: 7, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 7}, + ExcludedMembersIndexes: []group.MemberIndex{4, 5, 6, 8}, + } + + cloned := cloneAttempt(original) + if !reflect.DeepEqual(original, cloned) { + t.Fatalf("unexpected clone\nexpected: [%+v]\nactual: [%+v]", original, cloned) + } + + if &original.IncludedMembersIndexes[0] == &cloned.IncludedMembersIndexes[0] { + t.Fatal("included members slice should be copied") + } + + if &original.ExcludedMembersIndexes[0] == &cloned.ExcludedMembersIndexes[0] { + t.Fatal("excluded members slice should be copied") + } + + cloned.IncludedMembersIndexes[0] = 99 + if original.IncludedMembersIndexes[0] == cloned.IncludedMembersIndexes[0] { + t.Fatal("mutating clone should not mutate original") + } +} + +func TestCloneAttempt_Nil(t *testing.T) { + if cloneAttempt(nil) != nil { + t.Fatal("expected nil clone") + } +} diff --git a/pkg/frost/signing/backend.go b/pkg/frost/signing/backend.go new file mode 100644 index 0000000000..dcaceb366b --- /dev/null +++ b/pkg/frost/signing/backend.go @@ -0,0 +1,279 @@ +package signing + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +// ExecutionBackend represents a pluggable backend used by the FROST signing +// runtime. This enables seamless replacement of the transitional legacy engine +// with a native FROST/FFI-backed implementation. +type ExecutionBackend interface { + Name() string + Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, + ) (*Result, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +type nativeExecutionAvailabilityReporter interface { + NativeExecutionAvailable() bool +} + +var ( + // ErrNativeExecutionBackendUnavailable is returned when native backend is + // requested but not linked in the current build. + ErrNativeExecutionBackendUnavailable = fmt.Errorf( + "native FROST signing backend is unavailable in this build", + ) + + // executionBackend, nativeExecutionAdapter, registeredNativeExecBridge, and + // nativeExecutionFFIExecutor are process-global runtime state. Tests + // mutating this state must run sequentially; do not use t.Parallel in such + // tests. + executionBackendMutex sync.RWMutex + executionBackend ExecutionBackend = newLegacyExecutionBackend() + nativeExecutionAdapter NativeExecutionAdapter + registeredNativeExecBridge NativeExecutionBridge + nativeExecutionFFIExecutor NativeExecutionFFIExecutor + nativeExecutionFFISigningPrimitiveProviderForBuild NativeExecutionFFISigningPrimitiveProviderForBuild + nativeExecutionMode = nativeExecutionModeFallbackAllowed +) + +// LegacyExecutionBackendName is a stable identifier of the transitional +// legacy tECDSA bridge backend. +const LegacyExecutionBackendName = legacyExecutionBackendName + +// NativeExecutionBackendName is a stable identifier of the native FROST +// execution backend. +const NativeExecutionBackendName = nativeExecutionBackendName + +type nativeExecutionModeValue uint8 + +const ( + // nativeExecutionModeFallbackAllowed means the native adapter may fall back + // to transitional legacy execution when native cryptography is unavailable. + nativeExecutionModeFallbackAllowed nativeExecutionModeValue = iota + // nativeExecutionModeStrict requires native cryptographic execution and + // does not allow fallback to transitional legacy execution. + nativeExecutionModeStrict +) + +func currentExecutionBackend() ExecutionBackend { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return executionBackend +} + +// SetExecutionBackend sets a runtime execution backend. +func SetExecutionBackend(backend ExecutionBackend) error { + if backend == nil { + return fmt.Errorf("execution backend is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + executionBackend = backend + return nil +} + +// ResetExecutionBackend restores the default transitional legacy backend. +func ResetExecutionBackend() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + executionBackend = newLegacyExecutionBackend() + nativeExecutionMode = nativeExecutionModeFallbackAllowed +} + +// CurrentExecutionBackendName returns the active backend name. +func CurrentExecutionBackendName() string { + return currentExecutionBackend().Name() +} + +// NativeExecutionAvailable reports whether the real native tbtc-signer FROST +// engine is linked and registered in the current build/runtime. This is the +// strict availability signal: it deliberately does NOT consult the backend +// name, the adapter's NativeExecutionAvailable, or the FFI executor pointer. +// Those all report available too eagerly on a frost_native build that is NOT +// linked with the native signer (frost_tbtc_signer/cgo): the build init still +// registers the transitional FFI executor/legacy-delegate wrappers, so a +// FROST-enabled node on native/ffi would pass and only fall back to legacy (or +// fail on ErrNativeCryptographyUnavailable) when it hits DKG-persisted FROST +// signer material. Instead this checks the actual linked signer engine +// (nativeSignerEngineAvailable), which is registered only by +// frost_native && frost_tbtc_signer && cgo builds - the same engine the +// transitional primitive itself requires before executing natively. Callers +// that require native execution (e.g. FROST-enabled nodes) use this to fail +// fast at startup. +func NativeExecutionAvailable() bool { + return nativeSignerEngineAvailable() +} + +// SetExecutionBackendByName configures the runtime backend by a stable name. +// +// Supported values: +// - "", "legacy", "legacy-tecdsa-bridge": transitional legacy bridge backend +// - "native": native route with transitional fallback to legacy when native +// cryptography is unavailable +// - "ffi": strict native route; no fallback to legacy execution +func SetExecutionBackendByName(name string) error { + switch strings.ToLower(strings.TrimSpace(name)) { + case "", "legacy", legacyExecutionBackendName: + ResetExecutionBackend() + return nil + case "native": + previousMode := currentNativeExecutionMode() + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + + nativeBackend, err := currentNativeExecutionBackend() + if err != nil { + setNativeExecutionMode(previousMode) + return err + } + + if err := SetExecutionBackend(nativeBackend); err != nil { + setNativeExecutionMode(previousMode) + return err + } + + return nil + case "ffi": + previousMode := currentNativeExecutionMode() + setNativeExecutionMode(nativeExecutionModeStrict) + + nativeBackend, err := currentNativeExecutionBackend() + if err != nil { + setNativeExecutionMode(previousMode) + return err + } + + if err := SetExecutionBackend(nativeBackend); err != nil { + setNativeExecutionMode(previousMode) + return err + } + + return nil + default: + return fmt.Errorf("unknown FROST signing backend: [%s]", name) + } +} + +func currentNativeExecutionMode() nativeExecutionModeValue { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeExecutionMode +} + +func setNativeExecutionMode(mode nativeExecutionModeValue) { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionMode = mode +} + +func nativeExecutionFallbackAllowed() bool { + // Interactive-only mode (coarse-path retirement) suppresses EVERY legacy/coarse + // fallback, not only the inner FFI coarse primitive: if the native FFI path does + // not yield an interactive signature - including when it is unavailable before the + // adapter's own guard runs - the outer bridge/adapter must not delegate to the + // legacy backend either. This is the single gate those outer fallbacks consult, so + // failing it here closes them all. + if InteractiveSigningOnlyEnabled() { + return false + } + + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeExecutionMode == nativeExecutionModeFallbackAllowed +} + +// RegisterNativeExecutionAdapter sets a native adapter used by the +// native FROST execution backend. +func RegisterNativeExecutionAdapter(adapter NativeExecutionAdapter) error { + if adapter == nil { + return fmt.Errorf("native execution adapter is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionAdapter = adapter + + return nil +} + +// UnregisterNativeExecutionAdapter clears the native adapter registration. +func UnregisterNativeExecutionAdapter() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionAdapter = nil +} + +// RegisterNativeExecutionAdapterForBuild attempts to register the native +// adapter provided by the current build flavor. +// +// On default builds, this is a no-op. +// On `frost_native` builds, this registers the tagged native adapter. +// +// When TBTC_SIGNER_INIT_CONFIG_PATH is set, the operator demands config-mode +// FROST operation and this function does not return on failure: any state in +// which the FROST-native engine did not come up (either registration leg +// failed, or the build cannot register one at all) terminates the process. +// See enforceNativeInitConfigDemand for the decision record. With the path +// unset, registration failures keep the safe-by-default posture and the +// legacy bridge remains available. +func RegisterNativeExecutionAdapterForBuild() { + registerNativeExecutionAdapterForBuild() + RegisterNativeExecutionFFISigningPrimitiveForBuild() + enforceNativeInitConfigDemand() +} + +func currentNativeExecutionBackend() (ExecutionBackend, error) { + executionBackendMutex.RLock() + adapter := nativeExecutionAdapter + mode := nativeExecutionMode + executionBackendMutex.RUnlock() + + if adapter == nil { + return nil, fmt.Errorf( + "%w: no native execution adapter registered", + ErrNativeExecutionBackendUnavailable, + ) + } + + if mode == nativeExecutionModeStrict { + if reporter, ok := adapter.(nativeExecutionAvailabilityReporter); ok { + if !reporter.NativeExecutionAvailable() { + return nil, fmt.Errorf( + "%w: %w", + ErrNativeExecutionBackendUnavailable, + ErrNativeCryptographyUnavailable, + ) + } + } + } + + backend, err := newNativeExecutionBackend(adapter) + if err != nil { + return nil, fmt.Errorf( + "%w: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + + return backend, nil +} diff --git a/pkg/frost/signing/backend_test.go b/pkg/frost/signing/backend_test.go new file mode 100644 index 0000000000..43b93475ea --- /dev/null +++ b/pkg/frost/signing/backend_test.go @@ -0,0 +1,606 @@ +package signing + +import ( + "context" + "errors" + "math/big" + "reflect" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type mockExecutionBackend struct { + name string + + executeCalls int + lastRequest *Request + result *Result + err error + + registerUnmarshallersCalls int + lastChannel net.BroadcastChannel +} + +type mockNativeExecutionAdapter struct { + executeCalls int + lastRequest *Request + result *Result + err error + + registerUnmarshallersCalls int + lastChannel net.BroadcastChannel +} + +type mockNativeExecutionAdapterWithAvailability struct { + *mockNativeExecutionAdapter + nativeExecutionAvailable bool +} + +func (meb *mockExecutionBackend) Name() string { + return meb.name +} + +func (meb *mockExecutionBackend) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + meb.executeCalls++ + meb.lastRequest = request + return meb.result, meb.err +} + +func (meb *mockExecutionBackend) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + meb.registerUnmarshallersCalls++ + meb.lastChannel = channel +} + +func (mnea *mockNativeExecutionAdapter) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + mnea.executeCalls++ + mnea.lastRequest = request + return mnea.result, mnea.err +} + +func (mnea *mockNativeExecutionAdapter) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + mnea.registerUnmarshallersCalls++ + mnea.lastChannel = channel +} + +func (mneawa *mockNativeExecutionAdapterWithAvailability) NativeExecutionAvailable() bool { + return mneawa.nativeExecutionAvailable +} + +func TestCurrentExecutionBackendName_Default(t *testing.T) { + ResetExecutionBackend() + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected default backend name\nexpected: [%s]\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestSetExecutionBackend_Nil(t *testing.T) { + if err := SetExecutionBackend(nil); err == nil { + t.Fatal("expected nil backend error") + } +} + +func TestSetExecutionBackendByName(t *testing.T) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + if err := SetExecutionBackendByName(""); err != nil { + t.Fatalf("unexpected default backend config error: [%v]", err) + } + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected backend name for default config\\nexpected: [%s]\\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + if err := SetExecutionBackendByName("LEGACY"); err != nil { + t.Fatalf("unexpected legacy backend config error: [%v]", err) + } + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected backend name for legacy config\\nexpected: [%s]\\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + err := SetExecutionBackendByName("native") + if err == nil { + t.Fatal("expected native backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected native backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + if !nativeExecutionFallbackAllowed() { + t.Fatal("expected fallback-allowed mode for native backend selection") + } + + err = SetExecutionBackendByName("ffi") + if err == nil { + t.Fatal("expected ffi backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected ffi backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + if !nativeExecutionFallbackAllowed() { + t.Fatal( + "expected previous fallback-allowed mode after failed ffi backend selection", + ) + } + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected backend name after failed ffi config\\nexpected: [%s]\\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + err = SetExecutionBackendByName("unknown") + if err == nil { + t.Fatal("expected unknown backend error") + } +} + +func TestNativeExecutionFallbackAllowed_SuppressedByInteractiveOnly(t *testing.T) { + // Coarse-path retirement (Codex #4101 P2): interactive-only mode must close the + // OUTER native fallbacks too - the bridge/adapter consult this single gate before + // delegating to the legacy backend, so the flag has to flip it closed regardless + // of the execution mode, or a node with an unavailable FFI path would still sign + // over the legacy/coarse delegate. + previousMode := currentNativeExecutionMode() + t.Cleanup(func() { setNativeExecutionMode(previousMode) }) + + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + if !nativeExecutionFallbackAllowed() { + t.Fatal("baseline: the fallback-allowed mode must permit the outer fallback") + } + + t.Setenv(InteractiveSigningOnlyEnvVar, "true") + if nativeExecutionFallbackAllowed() { + t.Fatal("interactive-only mode must suppress the outer native fallback") + } +} + +func TestLegacyExecutionBackend_InteractiveOnlyRefusesTerminal(t *testing.T) { + // Coarse-path retirement (Codex #4101 P2): the DEFAULT backend is the legacy + // coarse/tECDSA signer, which the native bridge/adapter guards never touch. The + // flag must fail closed here too, terminally, so it cannot fail open under the + // documented default config. + t.Setenv(InteractiveSigningOnlyEnvVar, "true") + + _, err := newLegacyExecutionBackend().Execute( + context.Background(), log.Logger("test"), &Request{Message: big.NewInt(1)}, + ) + if !errors.Is(err, ErrTerminalSigningFailure) { + t.Fatalf("interactive-only must terminally refuse the legacy backend: %v", err) + } +} + +type unavailableNativeAdapter struct{} + +func (unavailableNativeAdapter) Execute( + context.Context, log.StandardLogger, *Request, +) (*Result, error) { + return nil, ErrNativeCryptographyUnavailable +} + +func (unavailableNativeAdapter) RegisterUnmarshallers(net.BroadcastChannel) {} + +func TestNativeExecutionBackend_InteractiveOnlyPromotesUnavailableToTerminal(t *testing.T) { + // Coarse-path retirement (Codex #4101 P2): when the native interactive path is + // unavailable and every fallback is suppressed, the outer refusal surfaces as a + // bare ErrNativeCryptographyUnavailable - promote it to terminal so the retry loop + // aborts instead of spinning. + backend, err := newNativeExecutionBackend(unavailableNativeAdapter{}) + if err != nil { + t.Fatalf("unexpected backend setup error: %v", err) + } + + t.Setenv(InteractiveSigningOnlyEnvVar, "true") + _, err = backend.Execute( + context.Background(), log.Logger("test"), &Request{Message: big.NewInt(1)}, + ) + if !errors.Is(err, ErrTerminalSigningFailure) { + t.Fatalf("interactive-only must promote the native unavailable error to terminal: %v", err) + } + + // With the flag OFF the unavailable error passes through unpromoted (no regression). + t.Setenv(InteractiveSigningOnlyEnvVar, "") + _, offErr := backend.Execute( + context.Background(), log.Logger("test"), &Request{Message: big.NewInt(1)}, + ) + if errors.Is(offErr, ErrTerminalSigningFailure) { + t.Fatalf("must not promote to terminal when the flag is off: %v", offErr) + } + if !errors.Is(offErr, ErrNativeCryptographyUnavailable) { + t.Fatalf("flag off must pass the native unavailable error through: %v", offErr) + } +} + +func TestSetExecutionBackendByName_NativeFailureRestoresPreviousMode( + t *testing.T, +) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + setNativeExecutionMode(nativeExecutionModeStrict) + if nativeExecutionFallbackAllowed() { + t.Fatal("expected strict mode before failed native backend selection") + } + + err := SetExecutionBackendByName("native") + if err == nil { + t.Fatal("expected native backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected native backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + + if nativeExecutionFallbackAllowed() { + t.Fatal("expected strict mode to be restored after failed native selection") + } + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected backend name after failed native config\\nexpected: [%s]\\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestSetExecutionBackendByName_FFIFailurePreservesNativeModeAndBackend( + t *testing.T, +) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + adapter := &mockNativeExecutionAdapterWithAvailability{ + mockNativeExecutionAdapter: &mockNativeExecutionAdapter{}, + nativeExecutionAvailable: false, + } + + if err := RegisterNativeExecutionAdapter(adapter); err != nil { + t.Fatalf("failed registering native execution adapter: [%v]", err) + } + + if err := SetExecutionBackendByName("native"); err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + if !nativeExecutionFallbackAllowed() { + t.Fatal("expected fallback-allowed mode after native backend selection") + } + if CurrentExecutionBackendName() != nativeExecutionBackendName { + t.Fatalf( + "unexpected backend name for native config\\nexpected: [%s]\\nactual: [%s]", + nativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + err := SetExecutionBackendByName("ffi") + if err == nil { + t.Fatal("expected ffi backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected ffi backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected strict-mode availability error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !nativeExecutionFallbackAllowed() { + t.Fatal( + "expected fallback-allowed mode to be preserved after failed ffi selection", + ) + } + if CurrentExecutionBackendName() != nativeExecutionBackendName { + t.Fatalf( + "unexpected backend name after failed ffi config\\nexpected: [%s]\\nactual: [%s]", + nativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestSetExecutionBackendByName_NativeAdapterRegistered(t *testing.T) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + expectedResult := &Result{Signature: &frost.Signature{}} + adapter := &mockNativeExecutionAdapter{ + result: expectedResult, + } + + if err := RegisterNativeExecutionAdapter(adapter); err != nil { + t.Fatalf("failed registering native execution adapter: [%v]", err) + } + + if err := SetExecutionBackendByName("ffi"); err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if CurrentExecutionBackendName() != nativeExecutionBackendName { + t.Fatalf( + "unexpected backend name for native config\\nexpected: [%s]\\nactual: [%s]", + nativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + if nativeExecutionFallbackAllowed() { + t.Fatal("expected strict mode for ffi backend selection") + } + + if err := SetExecutionBackendByName("native"); err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + if !nativeExecutionFallbackAllowed() { + t.Fatal("expected fallback-allowed mode for native backend selection") + } + + executeResult, err := Execute( + context.Background(), + nil, + big.NewInt(100), + "session-id", + 1, + nil, + 10, + 4, + nil, + nil, + nil, + ) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if executeResult != expectedResult { + t.Fatalf( + "unexpected execute result\\nexpected: [%+v]\\nactual: [%+v]", + expectedResult, + executeResult, + ) + } + + if adapter.executeCalls != 1 { + t.Fatalf("unexpected native execute calls count: [%d]", adapter.executeCalls) + } + + RegisterUnmarshallers(nil) + + if adapter.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected native register unmarshallers calls count: [%d]", + adapter.registerUnmarshallersCalls, + ) + } +} + +func TestSetExecutionBackendByName_FFIStrictAvailabilityCheck(t *testing.T) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + adapter := &mockNativeExecutionAdapterWithAvailability{ + mockNativeExecutionAdapter: &mockNativeExecutionAdapter{}, + nativeExecutionAvailable: false, + } + + if err := RegisterNativeExecutionAdapter(adapter); err != nil { + t.Fatalf("failed registering native execution adapter: [%v]", err) + } + + err := SetExecutionBackendByName("ffi") + if err == nil { + t.Fatal("expected ffi backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected ffi backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected strict-mode availability error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if err := SetExecutionBackendByName("native"); err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + if CurrentExecutionBackendName() != nativeExecutionBackendName { + t.Fatalf( + "unexpected backend name for native config\\nexpected: [%s]\\nactual: [%s]", + nativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestRegisterNativeExecutionAdapter_Nil(t *testing.T) { + if err := RegisterNativeExecutionAdapter(nil); err == nil { + t.Fatal("expected nil native adapter error") + } +} + +func TestRegisterNativeExecutionBridge_Nil(t *testing.T) { + if err := RegisterNativeExecutionBridge(nil); err == nil { + t.Fatal("expected nil native bridge error") + } +} + +func TestRegisterNativeExecutionFFIExecutor_Nil(t *testing.T) { + if err := RegisterNativeExecutionFFIExecutor(nil); err == nil { + t.Fatal("expected nil native FFI executor error") + } +} + +func TestExecute_DelegatesToCurrentBackend(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + expectedResult := &Result{Signature: &frost.Signature{}} + backend := &mockExecutionBackend{ + name: "mock", + result: expectedResult, + } + + if err := SetExecutionBackend(backend); err != nil { + t.Fatalf("failed setting backend: [%v]", err) + } + + attempt := &Attempt{ + Number: 2, + CoordinatorMemberIndex: 5, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 5}, + ExcludedMembersIndexes: []group.MemberIndex{3, 4, 6}, + } + + result, err := Execute( + context.Background(), + nil, + big.NewInt(100), + "session-id", + 1, + nil, + 10, + 4, + nil, + nil, + attempt, + ) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if backend.executeCalls != 1 { + t.Fatalf("unexpected execute calls count: [%d]", backend.executeCalls) + } + + received := backend.lastRequest + if received == nil { + t.Fatal("expected backend request") + } + + if received.Attempt == attempt { + t.Fatal("expected request attempt clone, got same pointer") + } + + if !reflect.DeepEqual(received.Attempt, attempt) { + t.Fatalf( + "unexpected request attempt\nexpected: [%+v]\nactual: [%+v]", + attempt, + received.Attempt, + ) + } + + received.Attempt.IncludedMembersIndexes[0] = 99 + if attempt.IncludedMembersIndexes[0] == 99 { + t.Fatal("mutating backend request attempt should not mutate caller attempt") + } +} + +func TestRegisterUnmarshallers_DelegatesToCurrentBackend(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + backend := &mockExecutionBackend{name: "mock"} + if err := SetExecutionBackend(backend); err != nil { + t.Fatalf("failed setting backend: [%v]", err) + } + + RegisterUnmarshallers(nil) + + if backend.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected register unmarshallers calls count: [%d]", + backend.registerUnmarshallersCalls, + ) + } + + if backend.lastChannel != nil { + t.Fatal("expected nil channel to be forwarded unchanged") + } +} diff --git a/pkg/frost/signing/distributed_dkg_multinode_net_e2e_frost_native_test.go b/pkg/frost/signing/distributed_dkg_multinode_net_e2e_frost_native_test.go new file mode 100644 index 0000000000..4797e94d40 --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_multinode_net_e2e_frost_native_test.go @@ -0,0 +1,246 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "context" + "encoding/hex" + "fmt" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/net" + netlocal "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This is the multi-NODE integration test for the distributed-DKG node wiring: n +// independent nodes, each with its OWN operator key, run the distributed FROST +// DKG over the REAL pkg/net broadcast transport through the exported +// RunDistributedDKGForSeats seam - the exact call the node's +// executeDistributedFrostDKG makes. Unlike the in-process-bus e2e, every round +// package crosses the real transport adapter and is membership-authenticated +// against the sender's operator key, and round-2 shares are ECIES-sealed to peers' +// per-DKG EPHEMERAL keys LEARNED from their authenticated round-1 broadcasts. So a +// passing run proves the production orchestration + net bus + key learning + +// encrypted round-2 all work together over real messaging. +// +// All nodes share ONE process-global engine, so each node's persist accumulates +// its seat's key package into one session; after every node persists, a threshold +// subset interactive-signs to a BIP-340 signature that verifies under the group +// key - proving the whole distributed DKG -> persist -> sign path over real +// transport. +func TestDistributedDKG_MultiNode_NetTransport(t *testing.T) { + setupRealCgoSignerState(t) + + const n = 3 + const threshold uint16 = 2 + members := []group.MemberIndex{1, 2, 3} + session := fmt.Sprintf("real-cgo-distributed-multinode-%d", realCgoSessionSeq.Add(1)) + message := bytesOf(0x42, 32) + + engine := &buildTaggedTBTCSignerEngine{} + + // One operator per seat; the MembershipValidator maps each seat to that + // operator's address so the DKG bus authenticates every round message's claimed + // seat against its authenticated sender key. The operator key stays bound to the + // channel (transport signing) - the DKG itself uses fresh per-seat ephemeral + // keys generated inside RunDistributedDKGForSeats, so no operator PRIVATE key is + // handed to the DKG (recipient-side forward secrecy). + chainSigning := local_v1.Connect(n, n).Signing() + publicKeys := make([]*operator.PublicKey, n) + addresses := make([]chain.Address, n) + for i := 0; i < n; i++ { + _, pub, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (seat %d): %v", i+1, err) + } + publicKeys[i] = pub + addresses[i] = chainSigning.PublicKeyBytesToAddress(operator.MarshalUncompressed(pub)) + } + validator := group.NewMembershipValidator(&testutils.MockLogger{}, addresses, chainSigning) + + // Canonical identifiers over the full participant set (as the node builds them). + identifierByID := make(map[group.MemberIndex]string, n) + for _, m := range members { + identifierByID[m] = CanonicalFROSTIdentifier(uint16(m)) + } + + channelName := fmt.Sprintf("%s-frost-dkg", session) + + // Join the shared named channel for every node BEFORE running, so no node's + // round-1 broadcast is lost to a not-yet-connected peer. + channels := make([]net.BroadcastChannel, n) + for i := 0; i < n; i++ { + channel, err := netlocal.ConnectWithKey(publicKeys[i]).BroadcastChannelFor(channelName) + if err != nil { + t.Fatalf("broadcast channel (seat %d): %v", i+1, err) + } + channels[i] = channel + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + type nodeOutcome struct { + member group.MemberIndex + persist map[group.MemberIndex]*NativeTBTCSignerDKGResult + err error + } + outcomes := make(chan nodeOutcome, n) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + i := i + member := members[i] + wg.Add(1) + go func() { + defer wg.Done() + persist, err := RunDistributedDKGForSeats( + ctx, + &testutils.MockLogger{}, + channels[i], + validator, + engine, + session, + members, + []group.MemberIndex{member}, // one seat per node + identifierByID, + threshold, + nil, // no prebuffer: this test subscribes before Start (no readiness barrier) + ) + outcomes <- nodeOutcome{member: member, persist: persist, err: err} + }() + } + wg.Wait() + close(outcomes) + + results := make(map[group.MemberIndex]*NativeTBTCSignerDKGResult, n) + for outcome := range outcomes { + // Skips in dev when the lib is absent/stale, but FAILS under the require-cgo + // gate (and fatals on any other error), so the cgo job cannot silently drop + // this coverage. + skipFrostUnavailable( + t, + fmt.Sprintf("multi-node distributed DKG (seat %d)", outcome.member), + outcome.err, + ) + seatResult, ok := outcome.persist[outcome.member] + if !ok { + t.Fatalf("node for seat %d returned no persist result for its own seat", outcome.member) + } + results[outcome.member] = seatResult + } + + // Every node agreed on ONE group key over the real transport. + var keyGroup string + for _, m := range members { + if keyGroup == "" { + keyGroup = results[m].KeyGroup + } else if results[m].KeyGroup != keyGroup { + t.Fatalf("node for seat %d disagreed on the group key: %s != %s", m, results[m].KeyGroup, keyGroup) + } + } + if len(keyGroup) != 66 { + t.Fatalf("group key must be a 33-byte compressed key (66 hex chars); got %d", len(keyGroup)) + } + t.Logf("%d nodes agreed on group key %s… over the real net transport", n, keyGroup[:16]) + + // ---- Interactive threshold signing under a DISTINCT session. ---- + // The DKG persisted under `session`, but interactive ROAST signing runs under a + // per-message RoastSessionID that is NOT the DKG session - the production shape. + // The engine resolves the wallet key by keyGroup, so signing under a different + // session must still work (a distributed-DKG wallet is signable ONLY this way). + signingSession := session + "-roast-signing" + signingMembers := members[:threshold] + includedParticipants := make([]uint16, len(signingMembers)) + for i, m := range signingMembers { + includedParticipants[i] = uint16(m) + } + + derived, err := engine.DeriveInteractiveAttemptContext( + signingSession, message, keyGroup, threshold, 0, includedParticipants, + ) + if err != nil { + t.Fatalf("derive interactive attempt context: %v", err) + } + frostIDByMember := make(map[group.MemberIndex]string, len(derived.FrostIdentifiers)) + for _, id := range derived.FrostIdentifiers { + frostIDByMember[group.MemberIndex(id.ParticipantIdentifier)] = id.FrostIdentifier + } + + var attemptID string + commitments := make([]nativeFROSTCommitment, 0, len(signingMembers)) + for _, m := range signingMembers { + open, err := engine.InteractiveSessionOpen( + signingSession, uint16(m), message, keyGroup, threshold, nil, derived.AttemptContext, + ) + if err != nil { + t.Fatalf("interactive session open (member %d): %v", m, err) + } + if attemptID == "" { + attemptID = open.AttemptID + } + commitmentData, err := engine.InteractiveRound1(signingSession, open.AttemptID, uint16(m)) + if err != nil { + t.Fatalf("interactive round 1 (member %d): %v", m, err) + } + commitments = append(commitments, nativeFROSTCommitment{ + Identifier: frostIDByMember[m], + Data: commitmentData, + }) + } + + signingPackage, err := engine.NewSigningPackage(message, commitments) + if err != nil { + t.Fatalf("new signing package: %v", err) + } + + shares := make([]nativeFROSTSignatureShare, 0, len(signingMembers)) + for _, m := range signingMembers { + shareData, err := engine.InteractiveRound2(signingSession, attemptID, uint16(m), signingPackage) + if err != nil { + t.Fatalf("interactive round 2 (member %d): %v", m, err) + } + shares = append(shares, nativeFROSTSignatureShare{ + Identifier: frostIDByMember[m], + Data: shareData, + }) + } + + signatureBytes, err := engine.InteractiveAggregate(signingSession, attemptID, signingPackage, shares, nil) + if err != nil { + t.Fatalf("interactive aggregate: %v", err) + } + if len(signatureBytes) != 64 { + t.Fatalf("aggregate signature length = %d, want 64", len(signatureBytes)) + } + + // Verify against the 32-byte x-only group key (the compressed key without its + // even-Y "02" prefix). + groupKeyBytes, err := hex.DecodeString(keyGroup[2:]) + if err != nil { + t.Fatalf("decode x-only group key: %v", err) + } + publicKey, err := schnorr.ParsePubKey(groupKeyBytes) + if err != nil { + t.Fatalf("parse group key: %v", err) + } + signature, err := schnorr.ParseSignature(signatureBytes) + if err != nil { + t.Fatalf("parse signature: %v", err) + } + if !signature.Verify(message, publicKey) { + t.Fatal("interactive threshold signature does not verify under the multi-node DKG group key") + } + t.Logf( + "multi-node distributed DKG -> persist -> interactive %d-of-%d signature verifies over real transport", + threshold, n, + ) +} diff --git a/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go new file mode 100644 index 0000000000..6c5ad5a19e --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go @@ -0,0 +1,216 @@ +//go:build frost_native + +package signing + +import ( + "context" + "encoding/binary" + "encoding/hex" + "fmt" + + "github.com/ipfs/go-log/v2" + + "github.com/keep-network/keep-core/pkg/crypto/ephemeral" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// NativeTBTCSignerDistributedDKGEngine is the engine capability the distributed +// DKG orchestration needs but the base NativeTBTCSignerEngine interface does not +// expose: the stateless three-round FROST primitives plus the op that persists a +// seat's own key package as signing material. The node type-asserts +// CurrentNativeTBTCSignerEngine() to this, mirroring how the transitional dealer +// path asserts NativeTBTCSignerSeededDKGEngine for RunDKGWithSeed. +type NativeTBTCSignerDistributedDKGEngine interface { + Part1(participantIdentifier string, maxSigners, minSigners uint16) (*NativeFROSTDKGPart1Result, error) + Part2( + secretPackage *NativeFROSTDKGRound1SecretPackage, + round1Packages []*NativeFROSTDKGRound1Package, + ) (*NativeFROSTDKGPart2Result, error) + Part3( + secretPackage *NativeFROSTDKGRound2SecretPackage, + round1Packages []*NativeFROSTDKGRound1Package, + round2Packages []*NativeFROSTDKGRound2Package, + ) (*NativeFROSTDKGResult, error) + PersistDistributedDKGKeyPackage( + sessionID string, + participantIdentifier uint16, + threshold uint16, + participantCount uint16, + keyPackage *NativeFROSTKeyPackage, + publicKeyPackage *NativeFROSTPublicKeyPackage, + ) (*NativeTBTCSignerDKGResult, error) +} + +// CanonicalFROSTIdentifier returns the canonical FROST identifier string for a +// participant: the identifier as a 32-byte big-endian scalar (value in the +// least-significant byte), hex-encoded and JSON-quoted. It matches the engine's +// participant_identifier_to_frost_identifier, which PersistDistributedDKGKeyPackage +// re-derives and rejects a mismatch against, and which the interactive signing +// path looks members up by. Callers build identifierByID over the FULL participant +// set with this (NOT the byte-0 scheme used by the stateless DKG tests). +func CanonicalFROSTIdentifier(participantIdentifier uint16) string { + var id [32]byte + binary.BigEndian.PutUint16(id[30:], participantIdentifier) + return fmt.Sprintf("%q", hex.EncodeToString(id[:])) +} + +// RunDistributedDKGForSeats runs a real distributed FROST DKG for every local +// seat of this node over the wallet broadcast channel and persists each seat's +// resulting key package as signing material, returning the per-seat persist +// result (all sharing the same group key). It replaces the transitional +// trusted-dealer RunDKGWithSeed for the node's wallet DKG. +// +// memberIndexes is the FULL participant set (the final compact DKG member space); +// localMemberIndexes are this node's seats in that same space; identifierByID must +// map EVERY member in memberIndexes to its CanonicalFROSTIdentifier. Round-2 shares +// are ECIES-sealed to peers' per-DKG EPHEMERAL keys (generated here, one per local +// seat) learned from their authenticated round-1 broadcasts, so the channel MUST be +// the membership-validated wallet channel; the operator key stays bound to that +// channel and never reaches the DKG. +// +// One orchestrator runs per local seat. All local runners are constructed (and +// thereby subscribed to the shared bus) BEFORE any of them starts, so no +// co-located seat's round-1 broadcast is missed once the channel loops it back. +func RunDistributedDKGForSeats( + ctx context.Context, + logger log.StandardLogger, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + engine NativeTBTCSignerDistributedDKGEngine, + session string, + memberIndexes []group.MemberIndex, + localMemberIndexes []group.MemberIndex, + identifierByID map[group.MemberIndex]string, + threshold uint16, + prebuffer *DKGMessagePrebuffer, +) (map[group.MemberIndex]*NativeTBTCSignerDKGResult, error) { + if len(localMemberIndexes) == 0 { + return nil, fmt.Errorf("no local seats to run the distributed DKG for") + } + + bus, err := NewBroadcastChannelDKGBus(ctx, logger, channel, membershipValidator) + if err != nil { + return nil, fmt.Errorf("cannot create the distributed DKG bus: [%v]", err) + } + + participantCount := uint16(len(memberIndexes)) + + // Construct every local runner FIRST so all local seats are subscribed to the + // bus before any of them broadcasts round 1. + runners := make(map[group.MemberIndex]*distributedDKGRunner, len(localMemberIndexes)) + for _, seat := range localMemberIndexes { + // A FRESH per-DKG ephemeral key pair per seat: peers seal this seat's round-2 + // shares to its ephemeral public key (learned from its authenticated round-1 + // broadcast) and it opens them with the ephemeral private key, which is + // discarded when the DKG ends. This gives the shares two-sided forward + // secrecy - a recorded broadcast plus a later operator-key leak reveals + // nothing, because the sealing key never existed at rest. + seatEphemeral, err := ephemeral.GenerateKeyPair() + if err != nil { + return nil, fmt.Errorf("cannot generate the ephemeral key for seat [%v]: [%v]", seat, err) + } + runner, err := newDistributedDKGRunner( + seat, + session, + memberIndexes, + identifierByID, + threshold, + engine, + bus, + seatEphemeral.PrivateKey, + seatEphemeral.PublicKey.Marshal(), + ) + if err != nil { + return nil, fmt.Errorf("cannot create the distributed DKG runner for seat [%v]: [%w]", seat, err) + } + runners[seat] = runner + } + + // Only now that every local seat is subscribed, begin delivering inbound + // messages - so no peer's early round-1 is handled with no subscriber and + // dropped (which the transport would not retransmit). + bus.Start() + + // Hand the prebuffer off to the live bus. It caught round-1 messages a peer broadcast + // after the readiness barrier released it but before this node reached Start (which + // the transport would neither deliver - no subscriber yet - nor retransmit). Draining + // and forwarding is race-free: the captured buffer is delivered AND any message the + // prebuffer catches around the drain instant is forwarded live, so none is lost in the + // handoff window. Deduped against live delivery by content hash. + if prebuffer != nil { + prebuffer.DrainAndForward(bus.Deliver) + } + + type seatOutcome struct { + member group.MemberIndex + persist *NativeTBTCSignerDKGResult + err error + } + outcomes := make(chan seatOutcome, len(runners)) + for seat, runner := range runners { + seat := seat + runner := runner + go func() { + dkgResult, err := runner.Run(ctx) + if err != nil { + outcomes <- seatOutcome{member: seat, err: fmt.Errorf("distributed DKG for seat [%v] failed: [%w]", seat, err)} + return + } + // dkgResult.KeyPackage.Data is this seat's long-term SECRET share. The engine + // holds the authoritative copy after PersistDistributedDKGKeyPackage; scrub the + // Go-side copy when this goroutine returns (on the persist-success AND + // persist-error paths) so it does not linger in the heap - and thus a later + // core dump or swap - past persistence. The returned persist result carries + // only public material (key group), so scrubbing does not affect it. + if dkgResult.KeyPackage != nil { + defer zeroBytes(dkgResult.KeyPackage.Data) + } + persisted, err := engine.PersistDistributedDKGKeyPackage( + session, + uint16(seat), + threshold, + participantCount, + dkgResult.KeyPackage, + dkgResult.PublicKeyPackage, + ) + if err != nil { + outcomes <- seatOutcome{member: seat, err: fmt.Errorf("cannot persist the key package for seat [%v]: [%w]", seat, err)} + return + } + outcomes <- seatOutcome{member: seat, persist: persisted} + }() + } + + persistBySeat := make(map[group.MemberIndex]*NativeTBTCSignerDKGResult, len(runners)) + var keyGroup string + var firstErr error + for range runners { + outcome := <-outcomes + if outcome.err != nil { + // Keep draining so no goroutine blocks on the channel, but remember the + // first failure to return once all seats have reported. + if firstErr == nil { + firstErr = outcome.err + } + continue + } + if keyGroup == "" { + keyGroup = outcome.persist.KeyGroup + } else if outcome.persist.KeyGroup != keyGroup { + if firstErr == nil { + firstErr = fmt.Errorf( + "local seats disagreed on the group key: [%s] != [%s]", + outcome.persist.KeyGroup, keyGroup, + ) + } + continue + } + persistBySeat[outcome.member] = outcome.persist + } + if firstErr != nil { + return nil, firstErr + } + + return persistBySeat, nil +} diff --git a/pkg/frost/signing/distributed_dkg_orchestration_frost_native_test.go b/pkg/frost/signing/distributed_dkg_orchestration_frost_native_test.go new file mode 100644 index 0000000000..81748dc318 --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_orchestration_frost_native_test.go @@ -0,0 +1,48 @@ +//go:build frost_native + +package signing + +import ( + "encoding/hex" + "fmt" + "testing" +) + +// TestCanonicalFROSTIdentifier pins the canonical identifier string the node +// builds for the DKG to the engine's participant_identifier_to_frost_identifier: +// the participant as a 32-byte big-endian scalar (value in the LEAST-significant +// byte), hex-encoded and JSON-quoted. The persist op re-derives and rejects a +// mismatch, so a regression here would silently break DKG-to-signing. +func TestCanonicalFROSTIdentifier(t *testing.T) { + // Member 1 -> 31 zero bytes then 0x01. + id1 := make([]byte, 32) + id1[31] = 0x01 + if got, want := CanonicalFROSTIdentifier(1), fmt.Sprintf("%q", hex.EncodeToString(id1)); got != want { + t.Fatalf("CanonicalFROSTIdentifier(1) = %s, want %s", got, want) + } + + // Member 256 -> the high byte sits at id[30], id[31] is zero (big-endian). + id256 := make([]byte, 32) + id256[30] = 0x01 + if got, want := CanonicalFROSTIdentifier(256), fmt.Sprintf("%q", hex.EncodeToString(id256)); got != want { + t.Fatalf("CanonicalFROSTIdentifier(256) = %s, want %s", got, want) + } + + // The value is quoted (the engine returns the JSON/textual form of the frost + // identifier, not a bare hex string). + got := CanonicalFROSTIdentifier(1) + if len(got) < 2 || got[0] != '"' || got[len(got)-1] != '"' { + t.Fatalf("CanonicalFROSTIdentifier(1) = %s, want a quoted string", got) + } + + // Injective over a range spanning the single-byte boundary (distinct scalars + // give distinct identifiers, which newDistributedDKGRunner requires). + seen := make(map[string]struct{}, 260) + for m := uint16(1); m <= 260; m++ { + id := CanonicalFROSTIdentifier(m) + if _, dup := seen[id]; dup { + t.Fatalf("CanonicalFROSTIdentifier is not injective at member %d", m) + } + seen[id] = struct{}{} + } +} diff --git a/pkg/frost/signing/distributed_dkg_persist_interactive_e2e_frost_native_test.go b/pkg/frost/signing/distributed_dkg_persist_interactive_e2e_frost_native_test.go new file mode 100644 index 0000000000..71b75cf9eb --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_persist_interactive_e2e_frost_native_test.go @@ -0,0 +1,298 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "context" + "encoding/hex" + "fmt" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This is the end-to-end proof that a DISTRIBUTED FROST DKG produces usable +// signing material through the persist bridge. The distributed-DKG orchestrator +// test (distributed_dkg_runner_real_cgo_frost_native_test.go) signs via the +// STATELESS path (passing each seat's raw KeyPackage bytes straight into Sign), +// which never touches persisted engine state. Production signs via the +// INTERACTIVE path, which loads the key from the engine's persisted session +// state (dkg_key_packages by member + the public key package) - material the +// dealer RunDKG persists but distributed Part3 previously discarded. +// +// So this test drives the real chain the tBTC node needs: +// +// distributed DKG (bus-orchestrated Part1/2/3) +// -> PersistDistributedDKGKeyPackage (the bridge under test) +// -> InteractiveSessionOpen / Round1 / Round2 / Aggregate +// -> a BIP-340 signature that verifies under the DKG group key. +// +// Crucially, each seat runs on its OWN engine with its OWN persisted state - +// exactly how nodes deploy, and unlike the dealer path (and the multi-seat +// interactive test) where every key package lives in one engine. A distributed +// node holds only its own secret key package; that this still opens, releases a +// share, and aggregates into a valid threshold signature is the whole point. +func TestDistributedDKG_PersistThenInteractiveSign(t *testing.T) { + setupRealCgoSignerState(t) + + const n = 3 + const threshold uint16 = 2 + members := []group.MemberIndex{1, 2, 3} + sessionID := fmt.Sprintf("real-cgo-distributed-persist-%d", realCgoSessionSeq.Add(1)) + message := bytesOf(0x42, 32) + + // One engine handle per seat. NOTE: in-process these are all handles to the + // SAME global engine state, so persisting each seat ACCUMULATES its key + // package into one shared session - that is what lets the seats sign together + // here, and it exercises the multi-seat-operator accumulate path. In + // production each node is a separate process with its own state holding only + // its own seat; that single-seat case (a node opening a session whose included + // set spans peers it does not hold) is covered separately at the end. + engines := make(map[group.MemberIndex]*buildTaggedTBTCSignerEngine, n) + for _, m := range members { + engines[m] = &buildTaggedTBTCSignerEngine{} + } + + // CANONICAL FROST identifiers: participant_identifier_to_frost_identifier(m) + // serializes the scalar m big-endian (member index in the LEAST-significant + // byte). Unlike the byte-0 scheme the stateless DKG tests use, these must be + // canonical here because the persist op re-derives each seat's identifier from + // its participant index and rejects a mismatch, and the interactive signing + // path looks members up by the same canonical identifier. + canonicalFrostIdentifier := func(m byte) string { + id := make([]byte, 32) + id[31] = m + return fmt.Sprintf("%q", hex.EncodeToString(id)) + } + identifiers := make(map[group.MemberIndex]string, n) + for _, m := range members { + identifiers[m] = canonicalFrostIdentifier(byte(m)) + } + + bus := NewInProcessDKGBus(64) + priv, pub := dkgTestKeys(t, members) + + // ---- Distributed DKG across the per-seat engines. ---- + runners := make(map[group.MemberIndex]*distributedDKGRunner, n) + for _, m := range members { + runner, err := newDistributedDKGRunner( + m, sessionID, members, identifiers, threshold, engines[m], bus, priv[m], pub[m], + ) + if err != nil { + t.Fatalf("new runner (member %d): %v", m, err) + } + runners[m] = runner + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + type seatOutcome struct { + result *NativeFROSTDKGResult + err error + } + outcomes := make(map[group.MemberIndex]*seatOutcome, n) + var mu sync.Mutex + var wg sync.WaitGroup + for _, m := range members { + m := m + wg.Add(1) + go func() { + defer wg.Done() + result, err := runners[m].Run(ctx) + mu.Lock() + outcomes[m] = &seatOutcome{result: result, err: err} + mu.Unlock() + }() + } + wg.Wait() + + for _, m := range members { + // Skips in dev when the lib is absent/stale, fails under the require-cgo gate. + skipFrostUnavailable(t, fmt.Sprintf("distributed DKG (member %d)", m), outcomes[m].err) + } + for _, m := range members { + if outcomes[m].result == nil || outcomes[m].result.KeyPackage == nil || + outcomes[m].result.PublicKeyPackage == nil { + t.Fatalf("member %d produced an incomplete DKG result", m) + } + } + groupVerifyingKey := outcomes[members[0]].result.PublicKeyPackage.VerifyingKey + + // ---- The bridge under test: persist each seat's OWN key package into its + // OWN engine, making a distributed-DKG share loadable for interactive signing. + // Every seat must derive the SAME key group, equal to the DKG group key. ---- + var keyGroup string + for _, m := range members { + persisted, err := engines[m].PersistDistributedDKGKeyPackage( + sessionID, + uint16(m), + threshold, + uint16(n), + outcomes[m].result.KeyPackage, + outcomes[m].result.PublicKeyPackage, + ) + if err != nil { + t.Fatalf("persist distributed DKG key package (member %d): %v", m, err) + } + if keyGroup == "" { + keyGroup = persisted.KeyGroup + } else if persisted.KeyGroup != keyGroup { + t.Fatalf("member %d persisted a different key group: %s != %s", m, persisted.KeyGroup, keyGroup) + } + } + // The persisted key group is the 33-byte COMPRESSED verifying key - the form + // run_dkg stores and the signing path consumes (even-Y taproot key, so a "02" + // prefix); the DKG result exposes the 32-byte x-only key. They must denote the + // same key. + if len(keyGroup) != 66 || keyGroup[2:] != groupVerifyingKey { + t.Fatalf("persisted key group %s does not match DKG group verifying key %s", keyGroup, groupVerifyingKey) + } + + // ---- Interactive threshold signing over the persisted material: any t of n. ---- + signingMembers := members[:threshold] // {1, 2} + includedParticipants := make([]uint16, len(signingMembers)) + for i, m := range signingMembers { + includedParticipants[i] = uint16(m) + } + + // The attempt context is deterministic; derive it once (any engine agrees). + derived, err := engines[signingMembers[0]].DeriveInteractiveAttemptContext( + sessionID, message, keyGroup, threshold, 0, includedParticipants, + ) + if err != nil { + t.Fatalf("derive interactive attempt context: %v", err) + } + frostIDByMember := make(map[group.MemberIndex]string, len(derived.FrostIdentifiers)) + for _, id := range derived.FrostIdentifiers { + frostIDByMember[group.MemberIndex(id.ParticipantIdentifier)] = id.FrostIdentifier + } + + // The node builds DKG identifiers with CanonicalFROSTIdentifier; it must equal + // what the engine derives for the same participant, or the DKG shares under an + // identifier the signing path cannot look up. + for _, m := range signingMembers { + if got := CanonicalFROSTIdentifier(uint16(m)); got != frostIDByMember[m] { + t.Fatalf( + "CanonicalFROSTIdentifier(%d) = %s, but the engine derived %s", + m, got, frostIDByMember[m], + ) + } + } + + // Open + Round1 for each signing seat ON ITS OWN engine (its persisted state). + var attemptID string + commitments := make([]nativeFROSTCommitment, 0, len(signingMembers)) + for _, m := range signingMembers { + open, err := engines[m].InteractiveSessionOpen( + sessionID, uint16(m), message, keyGroup, threshold, nil, derived.AttemptContext, + ) + if err != nil { + t.Fatalf("interactive session open (member %d): %v", m, err) + } + if attemptID == "" { + attemptID = open.AttemptID + } else if open.AttemptID != attemptID { + t.Fatalf("seats derived different attempt ids (%q vs %q)", attemptID, open.AttemptID) + } + commitmentData, err := engines[m].InteractiveRound1(sessionID, open.AttemptID, uint16(m)) + if err != nil { + t.Fatalf("interactive round 1 (member %d): %v", m, err) + } + commitments = append(commitments, nativeFROSTCommitment{ + Identifier: frostIDByMember[m], + Data: commitmentData, + }) + } + + // The coordinator assembles the signing package (stateless). + signingPackage, err := engines[signingMembers[0]].NewSigningPackage(message, commitments) + if err != nil { + t.Fatalf("new signing package: %v", err) + } + + // Round2 for each signing seat ON ITS OWN engine: each releases its share from + // its own persisted key package. + shares := make([]nativeFROSTSignatureShare, 0, len(signingMembers)) + for _, m := range signingMembers { + shareData, err := engines[m].InteractiveRound2(sessionID, attemptID, uint16(m), signingPackage) + if err != nil { + t.Fatalf("interactive round 2 (member %d): %v", m, err) + } + shares = append(shares, nativeFROSTSignatureShare{ + Identifier: frostIDByMember[m], + Data: shareData, + }) + } + + // The coordinator aggregates the shares into a BIP-340 signature. + signatureBytes, err := engines[signingMembers[0]].InteractiveAggregate( + sessionID, attemptID, signingPackage, shares, nil, + ) + if err != nil { + t.Fatalf("interactive aggregate: %v", err) + } + if len(signatureBytes) != 64 { + t.Fatalf("aggregate signature length = %d, want 64", len(signatureBytes)) + } + + // ---- GOLD: the interactive threshold signature verifies under the DKG group + // key - proving the persisted distributed-DKG shares sign correctly. ---- + // Verify against the 32-byte x-only group key (schnorr/BIP-340 form). + groupKeyBytes, err := hex.DecodeString(groupVerifyingKey) + if err != nil { + t.Fatalf("decode group verifying key: %v", err) + } + publicKey, err := schnorr.ParsePubKey(groupKeyBytes) + if err != nil { + t.Fatalf("parse group key: %v", err) + } + signature, err := schnorr.ParseSignature(signatureBytes) + if err != nil { + t.Fatalf("parse signature: %v", err) + } + if !signature.Verify(message, publicKey) { + t.Fatal("interactive threshold signature does not verify under the distributed-DKG group key") + } + t.Logf( + "distributed DKG -> persist -> interactive %d-of-%d signature verifies under group key %s…", + threshold, n, keyGroup[:16], + ) + + // ---- Single-seat-node coverage: a node that persisted ONLY its own key + // package must still OPEN a session whose included set spans OTHER members it + // does not hold - OPEN validates the included set against the public key + // package's verifying shares, not the local key-package map. This is the real + // distributed-node scenario the all-seats-accumulated path above does not + // exercise (there every included member is already in dkg_key_packages). ---- + soloSession := sessionID + "-solo" + soloResult, err := engines[members[0]].PersistDistributedDKGKeyPackage( + soloSession, + uint16(members[0]), + threshold, + uint16(n), + outcomes[members[0]].result.KeyPackage, + outcomes[members[0]].result.PublicKeyPackage, + ) + if err != nil { + t.Fatalf("persist single seat into a fresh session: %v", err) + } + soloDerived, err := engines[members[0]].DeriveInteractiveAttemptContext( + soloSession, message, soloResult.KeyGroup, threshold, 0, includedParticipants, + ) + if err != nil { + t.Fatalf("derive single-seat attempt context: %v", err) + } + // members[1] is a DKG participant (present in the public key package) but was + // NOT persisted into soloSession, so its member entry is absent from that + // session's key-package map; the open must still succeed. + if _, err := engines[members[0]].InteractiveSessionOpen( + soloSession, uint16(members[0]), message, soloResult.KeyGroup, threshold, nil, soloDerived.AttemptContext, + ); err != nil { + t.Fatalf("single-seat open with an included set spanning peers must succeed: %v", err) + } + t.Logf("single-seat node (only its own key package) opened a session spanning peers via the public key package") +} diff --git a/pkg/frost/signing/distributed_dkg_round2_seal_frost_native.go b/pkg/frost/signing/distributed_dkg_round2_seal_frost_native.go new file mode 100644 index 0000000000..93b7713741 --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_round2_seal_frost_native.go @@ -0,0 +1,127 @@ +//go:build frost_native + +package signing + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/crypto/ephemeral" + "github.com/keep-network/keep-core/pkg/operator" +) + +// Phase 1 of the distributed-DKG wiring makes round-2 CONFIDENTIAL. A FROST DKG +// round-2 package carries a per-recipient secret share, but it travels over the +// wallet BROADCAST channel - visible to the whole group - so a share sent in the +// clear would leak to every other member. This file seals each share to its +// recipient with an ECIES envelope over the existing pkg/crypto/ephemeral ECDH: +// the sender derives a symmetric key from a ONE-TIME ephemeral private key and +// the recipient's public key, encrypts the share, and carries the ephemeral +// PUBLIC key alongside the ciphertext. Only the recipient, via ECDH between its +// private key and that ephemeral public key, recovers the same symmetric key and +// opens the share. The sender's ephemeral private key is discarded after sealing, +// giving per-message forward secrecy on the sender side. +// +// The envelope is generic over ephemeral keys. The orchestrator supplies each +// recipient's per-DKG EPHEMERAL public key (learned from its authenticated +// round-1 broadcast), so BOTH sides use ephemeral keys discarded after the DKG, +// giving two-sided forward secrecy. (operatorPublicKeyToEphemeral converts an +// operator key to this type too, still used by the seal's own round-trip test.) +// +// SECURITY BOUNDARY: the envelope provides CONFIDENTIALITY ONLY. It does not +// authenticate the sealer, bind to a session/attempt, or prevent replay. Those +// come from (a) the sender-authenticated transport it runs over - the wallet +// broadcast channel authenticates the claimed sender against its operator key +// via the group MembershipValidator - and (b) FROST DKG Part3, which +// cryptographically verifies each round-2 share against the sender's round-1 +// commitment (so a garbage or misdirected share fails the DKG into the existing +// retry/blame path, not a signing breach). It MUST NOT be used over an +// unauthenticated transport. + +// sealedRound2Share is the confidential on-wire form of a round-2 share: a +// one-time ephemeral public key bound to the AES-ECDH ciphertext of the share. +type sealedRound2Share struct { + EphemeralPublicKey []byte `json:"ephemeralPublicKey"` + Ciphertext []byte `json:"ciphertext"` +} + +// sealRound2Share encrypts a round-2 share to the recipient's public key, +// producing a self-describing envelope any member can route but only the +// recipient can open. +func sealRound2Share(share []byte, recipient *ephemeral.PublicKey) (*sealedRound2Share, error) { + if recipient == nil { + return nil, fmt.Errorf("distributed dkg: nil recipient key for round-2 seal") + } + oneTime, err := ephemeral.GenerateKeyPair() + if err != nil { + return nil, fmt.Errorf("distributed dkg: generate ephemeral key: %w", err) + } + // Scrub the one-time sender ephemeral after sealing so it does not linger at + // rest; only its PUBLIC half travels in the envelope. + defer oneTime.PrivateKey.Zero() + ciphertext, err := oneTime.PrivateKey.Ecdh(recipient).Encrypt(share) + if err != nil { + return nil, fmt.Errorf("distributed dkg: seal round-2 share: %w", err) + } + return &sealedRound2Share{ + EphemeralPublicKey: oneTime.PublicKey.Marshal(), + Ciphertext: ciphertext, + }, nil +} + +// openRound2Share recovers a round-2 share sealed to us, using our per-DKG +// ephemeral private key and the ephemeral public key carried in the envelope. +// A share sealed to another member, or a tampered envelope, fails to open (the +// underlying box is authenticated) rather than yielding a wrong share. +func openRound2Share(sealed *sealedRound2Share, self *ephemeral.PrivateKey) ([]byte, error) { + if sealed == nil { + return nil, fmt.Errorf("distributed dkg: nil sealed round-2 share") + } + if self == nil { + return nil, fmt.Errorf("distributed dkg: nil private key for round-2 open") + } + oneTimePublic, err := ephemeral.UnmarshalPublicKey(sealed.EphemeralPublicKey) + if err != nil { + return nil, fmt.Errorf("distributed dkg: parse ephemeral public key: %w", err) + } + share, err := self.Ecdh(oneTimePublic).Decrypt(sealed.Ciphertext) + if err != nil { + return nil, fmt.Errorf("distributed dkg: open round-2 share: %w", err) + } + return share, nil +} + +// operatorPublicKeyToEphemeral converts a member's operator public key into the +// ephemeral (btcec) public key the seal envelope encrypts to. Operator keys are +// secp256k1 (the ephemeral package's only curve), so the uncompressed operator +// key parses directly; a non-secp256k1 key is rejected rather than silently +// mis-parsed. +func operatorPublicKeyToEphemeral(publicKey *operator.PublicKey) (*ephemeral.PublicKey, error) { + if publicKey == nil { + return nil, fmt.Errorf("distributed dkg: nil operator public key") + } + if publicKey.Curve != operator.Secp256k1 { + return nil, fmt.Errorf( + "distributed dkg: operator key curve [%v] is not secp256k1", publicKey.Curve, + ) + } + return ephemeral.UnmarshalPublicKey(operator.MarshalUncompressed(publicKey)) +} + +// operatorPrivateKeyToEphemeral converts this node's operator private key into +// the ephemeral private key used to open round-2 shares sealed to us. +func operatorPrivateKeyToEphemeral(privateKey *operator.PrivateKey) (*ephemeral.PrivateKey, error) { + if privateKey == nil { + return nil, fmt.Errorf("distributed dkg: nil operator private key") + } + if privateKey.Curve != operator.Secp256k1 { + return nil, fmt.Errorf( + "distributed dkg: operator key curve [%v] is not secp256k1", privateKey.Curve, + ) + } + scalar := make([]byte, 32) + privateKey.D.FillBytes(scalar) + // UnmarshalPrivateKey copies the scalar into the returned key, so scrub this + // raw copy of the long-lived operator secret on return. + defer zeroBytes(scalar) + return ephemeral.UnmarshalPrivateKey(scalar), nil +} diff --git a/pkg/frost/signing/distributed_dkg_round2_seal_frost_native_test.go b/pkg/frost/signing/distributed_dkg_round2_seal_frost_native_test.go new file mode 100644 index 0000000000..9178fb60e3 --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_round2_seal_frost_native_test.go @@ -0,0 +1,154 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "testing" + + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/crypto/ephemeral" + "github.com/keep-network/keep-core/pkg/operator" +) + +func TestOperatorKeyConversion_SealsAndOpens(t *testing.T) { + priv, pub, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key: %v", err) + } + + // Convert the operator keypair separately, then verify a share sealed to the + // converted PUBLIC key opens with the converted PRIVATE key - i.e. the + // conversions produce a matching ECDH keypair. + recipientPub, err := operatorPublicKeyToEphemeral(pub) + if err != nil { + t.Fatalf("convert public: %v", err) + } + recipientPriv, err := operatorPrivateKeyToEphemeral(priv) + if err != nil { + t.Fatalf("convert private: %v", err) + } + + share := []byte("a-share-sealed-to-an-operator-key") + sealed, err := sealRound2Share(share, recipientPub) + if err != nil { + t.Fatalf("seal: %v", err) + } + opened, err := openRound2Share(sealed, recipientPriv) + if err != nil { + t.Fatalf("open: %v", err) + } + if !bytes.Equal(opened, share) { + t.Fatal("share sealed to a converted operator public key did not open with the converted private key") + } +} + +func TestSealRound2Share_RoundTrip(t *testing.T) { + recipient, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatalf("recipient key: %v", err) + } + share := []byte("a-secret-frost-round-2-share-payload") + + sealed, err := sealRound2Share(share, recipient.PublicKey) + if err != nil { + t.Fatalf("seal: %v", err) + } + // The plaintext share must not appear anywhere in the sealed envelope. + if bytes.Contains(sealed.Ciphertext, share) { + t.Fatal("sealed ciphertext leaks the plaintext share") + } + if len(sealed.EphemeralPublicKey) == 0 { + t.Fatal("sealed envelope is missing its ephemeral public key") + } + + opened, err := openRound2Share(sealed, recipient.PrivateKey) + if err != nil { + t.Fatalf("open: %v", err) + } + if !bytes.Equal(opened, share) { + t.Fatalf("round-trip mismatch: got %q, want %q", opened, share) + } +} + +func TestSealRound2Share_WrongRecipientCannotOpen(t *testing.T) { + recipient, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatalf("recipient key: %v", err) + } + intruder, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatalf("intruder key: %v", err) + } + share := []byte("secret-share") + + sealed, err := sealRound2Share(share, recipient.PublicKey) + if err != nil { + t.Fatalf("seal: %v", err) + } + + // A member the share was NOT sealed to must not recover it: either the + // authenticated box fails to open, or (defensively) the result differs. + opened, err := openRound2Share(sealed, intruder.PrivateKey) + if err == nil && bytes.Equal(opened, share) { + t.Fatal("a non-recipient recovered the sealed share") + } +} + +func TestSealRound2Share_TamperedEnvelopeFails(t *testing.T) { + recipient, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatalf("recipient key: %v", err) + } + share := []byte("secret-share") + sealed, err := sealRound2Share(share, recipient.PublicKey) + if err != nil { + t.Fatalf("seal: %v", err) + } + if len(sealed.Ciphertext) == 0 { + t.Fatal("empty ciphertext") + } + // Flip a ciphertext bit; the authenticated box must not open it to the share. + sealed.Ciphertext[len(sealed.Ciphertext)-1] ^= 0xff + + opened, err := openRound2Share(sealed, recipient.PrivateKey) + if err == nil && bytes.Equal(opened, share) { + t.Fatal("a tampered envelope still opened to the original share") + } +} + +func TestSealRound2Share_MalformedEphemeralKeyFails(t *testing.T) { + recipient, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatalf("recipient key: %v", err) + } + sealed, err := sealRound2Share([]byte("secret-share"), recipient.PublicKey) + if err != nil { + t.Fatalf("seal: %v", err) + } + // A garbage ephemeral public key must fail to parse, not panic or open. + sealed.EphemeralPublicKey = []byte{0x00, 0x01, 0x02} + if _, err := openRound2Share(sealed, recipient.PrivateKey); err == nil { + t.Fatal("open with a malformed ephemeral public key must error") + } +} + +func TestSealRound2Share_NilInputs(t *testing.T) { + recipient, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatalf("recipient key: %v", err) + } + if _, err := sealRound2Share([]byte("x"), nil); err == nil { + t.Fatal("seal to a nil recipient must error") + } + if _, err := openRound2Share(nil, recipient.PrivateKey); err == nil { + t.Fatal("open of a nil envelope must error") + } + sealed, err := sealRound2Share([]byte("x"), recipient.PublicKey) + if err != nil { + t.Fatalf("seal: %v", err) + } + if _, err := openRound2Share(sealed, nil); err == nil { + t.Fatal("open with a nil private key must error") + } +} diff --git a/pkg/frost/signing/distributed_dkg_runner_bus_net_frost_native.go b/pkg/frost/signing/distributed_dkg_runner_bus_net_frost_native.go new file mode 100644 index 0000000000..ef90804a23 --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_runner_bus_net_frost_native.go @@ -0,0 +1,438 @@ +//go:build frost_native + +package signing + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "sync" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This file implements the production DKGBus over keep-core's pkg/net +// BroadcastChannel - the transport the distributed-DKG orchestrator runs on in a +// real deployment (the wallet DKG broadcast channel, already opened and +// membership-filtered by pkg/tbtc). It mirrors the ROAST runner bus +// (roast_runner_bus_net_frost_native.go): it authenticates a message's CLAIMED +// sender against the message's authenticated operator public key BEFORE +// delivering, and demuxes into bounded per-subscriber streams with non-blocking +// sends so a slow or flooding peer never blocks an honest broadcaster. +// +// Round-1 messages are broadcast (Recipient 0); round-2 messages are addressed +// (Recipient set) but still delivered to every subscriber - the orchestrator +// keeps only the ones addressed to it, and only it can OPEN a share sealed to it. +// Confidentiality of round-2 shares comes from the seal envelope, not the +// transport; the transport provides sender authentication and the session +// discriminator the orchestrator's collectors require. + +// dkgTransportType maps each DKG message type to the pkg/net Type() string the +// BroadcastChannel dispatches on. +var dkgTransportType = map[dkgMessageType]string{ + dkgRound1Message: "frost/dkg_runner/round1", + dkgRound2Message: "frost/dkg_runner/round2", +} + +// dkgTransportMessage is the wire envelope for one dkgMessage. The two DKG +// message types share this body and are distinguished by Type() (set per +// registered unmarshaler). The body carries the CLAIMED sender seat, the +// addressed recipient (0 for a round-1 broadcast), the attempt session, the +// sender's per-DKG EPHEMERAL round-2 sealing public key (round-1 only), and the +// opaque round payload. The sender seat is authenticated by the receive handler +// against the message's operator signature; the ephemeral key rides inside that +// authenticated message, so it too cannot be substituted by a MITM. +type dkgTransportMessage struct { + messageType dkgMessageType + sender group.MemberIndex + recipient group.MemberIndex + session string + ephemeralPublicKey []byte + payload []byte +} + +// Type returns the pkg/net dispatch tag for this message's DKG round type. +func (m *dkgTransportMessage) Type() string { return dkgTransportType[m.messageType] } + +// Marshal encodes the body as: sender(4 BE) || recipient(4 BE) || +// session_len(2 BE) || session || eph_len(2 BE) || ephemeral_public_key || +// payload. Each length-prefixed field keeps the boundaries unambiguous. +func (m *dkgTransportMessage) Marshal() ([]byte, error) { + if m.sender == 0 { + return nil, fmt.Errorf("dkg transport: sender is zero") + } + if len(m.session) > 0xffff { + return nil, fmt.Errorf("dkg transport: session length [%d] exceeds the 16-bit cap", len(m.session)) + } + if len(m.ephemeralPublicKey) > 0xffff { + return nil, fmt.Errorf("dkg transport: ephemeral key length [%d] exceeds the 16-bit cap", len(m.ephemeralPublicKey)) + } + out := make([]byte, 12+len(m.session)+len(m.ephemeralPublicKey)+len(m.payload)) + binary.BigEndian.PutUint32(out[0:4], uint32(m.sender)) + binary.BigEndian.PutUint32(out[4:8], uint32(m.recipient)) + binary.BigEndian.PutUint16(out[8:10], uint16(len(m.session))) + off := 10 + off += copy(out[off:off+len(m.session)], m.session) + binary.BigEndian.PutUint16(out[off:off+2], uint16(len(m.ephemeralPublicKey))) + off += 2 + off += copy(out[off:off+len(m.ephemeralPublicKey)], m.ephemeralPublicKey) + copy(out[off:], m.payload) + return out, nil +} + +// Unmarshal decodes a body produced by Marshal. messageType is preset by the +// registered unmarshaler (one per Type() string), so it is not carried on wire. +// It rejects a non-canonical sender/recipient at the decode boundary before the +// truncating cast to group.MemberIndex (uint8). +func (m *dkgTransportMessage) Unmarshal(data []byte) error { + const prefix = 10 + if len(data) < prefix { + return fmt.Errorf( + "dkg transport: message length [%d] shorter than the %d-byte header", + len(data), prefix, + ) + } + rawSender := binary.BigEndian.Uint32(data[0:4]) + if rawSender == 0 || rawSender > uint32(group.MaxMemberIndex) { + return fmt.Errorf( + "dkg transport: sender id [%d] out of range [1, %d]", rawSender, group.MaxMemberIndex, + ) + } + rawRecipient := binary.BigEndian.Uint32(data[4:8]) + if rawRecipient > uint32(group.MaxMemberIndex) { + return fmt.Errorf( + "dkg transport: recipient id [%d] out of range [0, %d]", rawRecipient, group.MaxMemberIndex, + ) + } + sessionLen := int(binary.BigEndian.Uint16(data[8:10])) + off := prefix + if len(data) < off+sessionLen { + return fmt.Errorf("dkg transport: message truncated in the session field") + } + session := string(data[off : off+sessionLen]) + off += sessionLen + if len(data) < off+2 { + return fmt.Errorf("dkg transport: message truncated before the ephemeral-key length") + } + ephLen := int(binary.BigEndian.Uint16(data[off : off+2])) + off += 2 + if len(data) < off+ephLen { + return fmt.Errorf("dkg transport: message truncated in the ephemeral key field") + } + m.sender = group.MemberIndex(rawSender) + m.recipient = group.MemberIndex(rawRecipient) + m.session = session + m.ephemeralPublicKey = append([]byte(nil), data[off:off+ephLen]...) + off += ephLen + m.payload = append([]byte(nil), data[off:]...) + return nil +} + +func registerDKGTransportUnmarshalers(channel net.BroadcastChannel) { + for messageType := range dkgTransportType { + mt := messageType + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &dkgTransportMessage{messageType: mt} + }) + } +} + +const ( + // defaultDKGBusStreamBuffer bounds each per-subscriber stream. Because round-2 + // messages are delivered only to their addressed recipient, a subscriber's + // honest per-stream volume is O(n): round-1 is one per member and round-2 is + // one per OTHER member (those addressed to this seat). 1024 sits well above the + // largest tBTC group (MaxMemberIndex 255), so honest operation never overflows; + // only a flooding peer can, which degrades the attempt to a retry. + defaultDKGBusStreamBuffer = 1024 + // defaultDKGBusSeenBound caps the per-subscriber dedup set so a peer flooding + // distinct messages cannot grow it without bound; on overflow it resets. + defaultDKGBusSeenBound = 4096 +) + +// broadcastChannelDKGBus is the production DKGBus over a net.BroadcastChannel. +type broadcastChannelDKGBus struct { + ctx context.Context + logger log.StandardLogger + channel net.BroadcastChannel + membershipValidator *group.MembershipValidator + streamBuffer int + seenBound int + + mu sync.Mutex + subscribers []*dkgBusSubscriber +} + +// NewBroadcastChannelDKGBus returns a DKGBus over the given wallet DKG broadcast +// channel. It registers the DKG message unmarshalers and installs a receive +// handler for the lifetime of ctx; cancel ctx (e.g. at DKG conclusion) to stop +// receiving. The channel and membershipValidator are the ones pkg/tbtc already +// carries for the DKG; this adapter does not create them. +func NewBroadcastChannelDKGBus( + ctx context.Context, + logger log.StandardLogger, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, +) (DKGBus, error) { + if ctx == nil { + return nil, fmt.Errorf("dkg bus: context is nil") + } + if channel == nil { + return nil, fmt.Errorf("dkg bus: broadcast channel is nil") + } + if membershipValidator == nil { + return nil, fmt.Errorf("dkg bus: membership validator is nil") + } + if logger == nil { + logger = log.Logger("frost-distributed-dkg-bus") + } + + b := &broadcastChannelDKGBus{ + ctx: ctx, + logger: logger, + channel: channel, + membershipValidator: membershipValidator, + streamBuffer: defaultDKGBusStreamBuffer, + seenBound: defaultDKGBusSeenBound, + } + + registerDKGTransportUnmarshalers(channel) + + return b, nil +} + +// Start installs the channel Recv handler that demuxes inbound messages to +// subscribers. It is deferred out of the constructor so the orchestrator can +// Subscribe every local seat FIRST: a message handled before any subscriber +// exists is dropped, and the transport's retransmission layer may have already +// marked it seen, so it would never be redelivered and the DKG would stall. +func (b *broadcastChannelDKGBus) Start() { + b.channel.Recv(b.ctx, b.handleMessage) +} + +// Replay pushes messages a DKGMessagePrebuffer captured before Start (from before the +// readiness barrier) through the same handleMessage path as live delivery. Any that +// also arrived live are dropped by handleMessage's content-hash dedup, so this is safe +// to call right after Start. This closes the cross-node window where a peer's round-1 +// broadcast (after the barrier released it) outran a slower peer's Start. +func (b *broadcastChannelDKGBus) Replay(messages []net.Message) { + for _, m := range messages { + b.handleMessage(m) + } +} + +// Deliver feeds a single message through the same receive path as live delivery. It is +// the per-message sink a DKGMessagePrebuffer forwards to at handoff (DrainAndForward), +// deduped by handleMessage's content hash. +func (b *broadcastChannelDKGBus) Deliver(message net.Message) { + b.handleMessage(message) +} + +// DKGMessagePrebuffer captures inbound DKG transport messages from the moment DKG setup +// begins - BEFORE the cross-node readiness-announcement barrier releases peers - and +// holds them until the bus is Started and every seat has Subscribed, at which point the +// orchestrator hands off via DrainAndForward. Without it, a fast peer's round-1 broadcast +// can arrive at a slower peer before that peer installed its DKG receiver; the transport +// drops it and marks the pubsub sequence seen, suppressing retransmission and stalling +// collectRound1. +type DKGMessagePrebuffer struct { + mu sync.Mutex + messages []net.Message + capacity int + // forward, once set by DrainAndForward, is where the capture handler sends every + // subsequent message instead of buffering it. This makes the handoff race-free: the + // handler and DrainAndForward share mu, so a message is EITHER in the drained buffer + // OR forwarded live - never dropped in the window around the handoff. + forward func(net.Message) +} + +// StartDKGMessagePrebuffer registers the DKG transport unmarshalers (so inbound DKG +// messages decode even before the bus exists) and starts capturing them off the channel. +// Call it BEFORE the readiness barrier. It captures at most a bounded number of messages +// (a DKG's round-1/round-2 traffic is small; the bound guards against unbounded growth +// from a misbehaving or noisy channel). +func StartDKGMessagePrebuffer( + ctx context.Context, + channel net.BroadcastChannel, +) *DKGMessagePrebuffer { + registerDKGTransportUnmarshalers(channel) + pb := &DKGMessagePrebuffer{capacity: 4096} + channel.Recv(ctx, func(m net.Message) { + // Only DKG transport messages; announcement/other traffic is ignored. + if _, ok := m.Payload().(*dkgTransportMessage); !ok { + return + } + pb.mu.Lock() + forward := pb.forward + if forward == nil { + if len(pb.messages) < pb.capacity { + pb.messages = append(pb.messages, m) + } + pb.mu.Unlock() + return + } + pb.mu.Unlock() + // Post-handoff: forward straight to the live bus (deduped there). Called OUTSIDE + // the lock so the sink cannot contend with a concurrent DrainAndForward. + forward(m) + }) + return pb +} + +// DrainAndForward hands the prebuffer off to the live bus with no lost-message window: it +// snapshots-and-clears the captured messages AND installs sink as the destination for any +// further captures, atomically under one lock. Because the capture handler takes the same +// lock, every message is EITHER in the snapshot (then delivered through sink below) OR +// forwarded to sink by the handler - so a message received before Start but delivered by +// the channel's Recv goroutine around the handoff cannot be dropped. Call once, right +// after Start and after every seat has Subscribed; the bus dedups by content hash, so +// messages that also arrive live are ignored. +func (pb *DKGMessagePrebuffer) DrainAndForward(sink func(net.Message)) { + if pb == nil || sink == nil { + return + } + pb.mu.Lock() + buffered := pb.messages + pb.messages = nil + pb.forward = sink + pb.mu.Unlock() + for _, m := range buffered { + sink(m) + } +} + +// Subscribe registers a receiver and returns its typed streams. The orchestrator +// subscribes in its constructor, before broadcasting. +func (b *broadcastChannelDKGBus) Subscribe(member group.MemberIndex) *dkgBusSubscriber { + s := &dkgBusSubscriber{ + member: member, + round1: make(chan dkgMessage, b.streamBuffer), + round2: make(chan dkgMessage, b.streamBuffer), + seen: make(map[[32]byte]struct{}), + } + b.mu.Lock() + b.subscribers = append(b.subscribers, s) + b.mu.Unlock() + return s +} + +// Broadcast publishes msg to the channel; pkg/net handles retransmission, so a +// Send error is logged, not surfaced. +func (b *broadcastChannelDKGBus) Broadcast(msg dkgMessage) { + wire := &dkgTransportMessage{ + messageType: msg.Type, + sender: msg.Sender, + recipient: msg.Recipient, + session: msg.Session, + ephemeralPublicKey: msg.SenderPublicKey, + payload: msg.Payload, + } + if err := b.channel.Send(b.ctx, wire); err != nil { + b.logger.Warnf("dkg bus: failed to broadcast [%s] message: [%v]", wire.Type(), err) + } +} + +// handleMessage is the single Recv handler. It authenticates the claimed sender +// seat against the message's authenticated operator public key, then demuxes the +// message into every subscriber's typed stream (non-blocking, deduped). +func (b *broadcastChannelDKGBus) handleMessage(m net.Message) { + wire, ok := m.Payload().(*dkgTransportMessage) + if !ok { + return + } + // Bind the CLAIMED seat to the AUTHENTICATED operator public key. A spoofed + // seat (one the sender's key was not selected to) is dropped here, before the + // orchestrator ever sees it. + if !b.membershipValidator.IsValidMembership(wire.sender, m.SenderPublicKey()) { + b.logger.Warnf( + "dkg bus: dropping [%s] message claiming unauthenticated seat [%d]", + wire.Type(), wire.sender, + ) + return + } + + msg := dkgMessage{ + Type: wire.messageType, + Session: wire.session, + Sender: wire.sender, + // The sender's per-DKG EPHEMERAL round-2 sealing key, carried on the wire. + // It is trustworthy because it rides inside the message whose operator + // signature we just authenticated the CLAIMED seat against (above), so a + // MITM cannot substitute it; using an ephemeral (not the long-lived operator + // key) gives the round-2 shares recipient-side forward secrecy. + SenderPublicKey: wire.ephemeralPublicKey, + Recipient: wire.recipient, + Payload: wire.payload, + } + hash := msg.contentHash() + + b.mu.Lock() + subscribers := append([]*dkgBusSubscriber(nil), b.subscribers...) + b.mu.Unlock() + + for _, s := range subscribers { + // A round-2 message is ADDRESSED: deliver it only to its recipient's + // subscriber, so a subscriber buffers O(n) round-2 messages (those for it), + // not the O(n^2) whole-group fan-out that would overflow the stream at + // mainnet group sizes. Round-1 messages are broadcast to every subscriber. + if msg.Type == dkgRound2Message && s.member != msg.Recipient { + continue + } + s.deliverNonBlocking(hash, msg, b.seenBound) + } +} + +// contentHash is the full-message identity used for retransmission dedup; it +// covers every field so a body-different message hashes differently. +func (m dkgMessage) contentHash() [32]byte { + h := sha256.New() + h.Write([]byte{byte(m.Type), byte(m.Sender), byte(m.Recipient)}) + h.Write([]byte(m.Session)) + h.Write(m.SenderPublicKey) + h.Write(m.Payload) + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +func (s *dkgBusSubscriber) streamFor(t dkgMessageType) chan dkgMessage { + switch t { + case dkgRound1Message: + return s.round1 + case dkgRound2Message: + return s.round2 + default: + return nil + } +} + +// deliverNonBlocking routes msg into the matching typed stream WITHOUT blocking: +// on a full stream it drops the newest (earlier, useful messages stay queued). +// It dedups by full content hash per subscriber; seenBound caps the dedup set. +func (s *dkgBusSubscriber) deliverNonBlocking(hash [32]byte, msg dkgMessage, seenBound int) { + stream := s.streamFor(msg.Type) + if stream == nil { + return + } + // Own the payload bytes per delivery so no receiver can mutate another's view. + delivered := msg + delivered.Payload = append([]byte(nil), msg.Payload...) + + s.mu.Lock() + defer s.mu.Unlock() + if _, dup := s.seen[hash]; dup { + return + } + select { + case stream <- delivered: + if seenBound > 0 && len(s.seen) >= seenBound { + s.seen = make(map[[32]byte]struct{}) + } + s.seen[hash] = struct{}{} + default: + // Stream full: drop the newest (a flood -> retry), leaving it un-seen. + } +} diff --git a/pkg/frost/signing/distributed_dkg_runner_bus_net_frost_native_test.go b/pkg/frost/signing/distributed_dkg_runner_bus_net_frost_native_test.go new file mode 100644 index 0000000000..f07b584c0d --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_runner_bus_net_frost_native_test.go @@ -0,0 +1,355 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "testing" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// fakeDKGNetMessage is a minimal net.Message for exercising the DKG bus receive +// path (sender authentication + demux) without standing up a real network. +type fakeDKGNetMessage struct { + senderPublicKey []byte + payload interface{} +} + +func (m fakeDKGNetMessage) TransportSenderID() net.TransportIdentifier { return nil } +func (m fakeDKGNetMessage) SenderPublicKey() []byte { return m.senderPublicKey } +func (m fakeDKGNetMessage) Payload() interface{} { return m.payload } +func (m fakeDKGNetMessage) Seqno() uint64 { return 0 } +func (m fakeDKGNetMessage) Type() string { + if w, ok := m.payload.(*dkgTransportMessage); ok { + return w.Type() + } + return "" +} + +// dkgBusAuthFixture builds a three-seat group with a MULTI-SEAT operator +// (operator A holds seats 1 and 3; operator B holds seat 2) and a bus wired to +// the resulting MembershipValidator. +type dkgBusAuthFixture struct { + bus *broadcastChannelDKGBus + operatorA []byte // seats 1 and 3 + operatorB []byte // seat 2 + outsider []byte // not selected +} + +func newDKGBusAuthFixture(t *testing.T, streamSize int) dkgBusAuthFixture { + t.Helper() + signing := local_v1.Connect(3, 3).Signing() + + key := func() []byte { + _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("generate operator key: %v", err) + } + return operator.MarshalUncompressed(publicKey) + } + operatorA, operatorB, outsider := key(), key(), key() + + addrA := signing.PublicKeyBytesToAddress(operatorA) + addrB := signing.PublicKeyBytesToAddress(operatorB) + // Ordered seats: 1 -> A, 2 -> B, 3 -> A (operator A is multi-seat). + validator := group.NewMembershipValidator( + &testutils.MockLogger{}, + []chain.Address{addrA, addrB, addrA}, + signing, + ) + + bus := &broadcastChannelDKGBus{ + logger: &testutils.MockLogger{}, + membershipValidator: validator, + streamBuffer: streamSize, + seenBound: defaultDKGBusSeenBound, + } + return dkgBusAuthFixture{bus: bus, operatorA: operatorA, operatorB: operatorB, outsider: outsider} +} + +// dkgRound1Msg builds a round-1 message authored (signed) by authorKey. For test +// simplicity the sender's on-wire ephemeral sealing key is set to authorKey too; +// TestBroadcastChannelDKGBus_CarriesWireEphemeralKey exercises the case where they +// differ. +func dkgRound1Msg(sender group.MemberIndex, authorKey []byte, session, payload string) fakeDKGNetMessage { + return fakeDKGNetMessage{ + senderPublicKey: authorKey, + payload: &dkgTransportMessage{ + messageType: dkgRound1Message, + sender: sender, + session: session, + ephemeralPublicKey: authorKey, + payload: []byte(payload), + }, + } +} + +func dkgRound2Msg(sender, recipient group.MemberIndex, authorKey []byte, session, payload string) fakeDKGNetMessage { + return fakeDKGNetMessage{ + senderPublicKey: authorKey, + payload: &dkgTransportMessage{ + messageType: dkgRound2Message, + sender: sender, + recipient: recipient, + session: session, + ephemeralPublicKey: authorKey, + payload: []byte(payload), + }, + } +} + +func TestBroadcastChannelDKGBus_AuthenticatedMessagesDemuxed(t *testing.T) { + f := newDKGBusAuthFixture(t, 8) + // Subscribe as seat 2 so it receives the round-2 message addressed to it. + sub := f.bus.Subscribe(2) + + // Operator A authentically sends a round-1 broadcast as seat 1 and a round-2 + // message as seat 3 (both seats it holds), the latter addressed to seat 2. + f.bus.handleMessage(dkgRound1Msg(1, f.operatorA, "sess", "round1-from-1")) + f.bus.handleMessage(dkgRound2Msg(3, 2, f.operatorA, "sess", "round2-from-3")) + + select { + case msg := <-sub.round1: + if msg.Sender != 1 || msg.Session != "sess" || string(msg.Payload) != "round1-from-1" || msg.Type != dkgRound1Message { + t.Fatalf("unexpected round-1 delivery: %+v", msg) + } + // The delivered SenderPublicKey is the sender's ephemeral sealing key + // carried on the wire (here equal to the author key). + if !bytes.Equal(msg.SenderPublicKey, f.operatorA) { + t.Fatalf("round-1 SenderPublicKey must be the wire ephemeral key") + } + default: + t.Fatal("expected the authenticated round-1 message on the round-1 stream") + } + select { + case msg := <-sub.round2: + if msg.Sender != 3 || msg.Recipient != 2 || string(msg.Payload) != "round2-from-3" || msg.Type != dkgRound2Message { + t.Fatalf("unexpected round-2 delivery: %+v", msg) + } + if !bytes.Equal(msg.SenderPublicKey, f.operatorA) { + t.Fatalf("round-2 SenderPublicKey must be the wire ephemeral key") + } + default: + t.Fatal("expected the authenticated round-2 message on the round-2 stream") + } +} + +// TestBroadcastChannelDKGBus_CarriesWireEphemeralKey proves the delivered +// SenderPublicKey (peers' round-2 sealing key) is the sender's EPHEMERAL key +// carried on the wire - NOT the operator key the message was authenticated +// against. So a seat seals to a key that never exists at rest (forward secrecy), +// while the seat itself is still bound to its operator for authentication. +func TestBroadcastChannelDKGBus_CarriesWireEphemeralKey(t *testing.T) { + f := newDKGBusAuthFixture(t, 8) + sub := f.bus.Subscribe(1) + + // Operator A (seat 1) authenticates the message, but the on-wire ephemeral + // sealing key is DISTINCT from the operator key. + ephemeralKey := []byte("distinct-ephemeral-round2-sealing-key") + msg := dkgRound1Msg(1, f.operatorA, "sess", "round1") + msg.payload.(*dkgTransportMessage).ephemeralPublicKey = ephemeralKey + f.bus.handleMessage(msg) + + select { + case delivered := <-sub.round1: + if bytes.Equal(delivered.SenderPublicKey, f.operatorA) { + t.Fatal("delivered sealing key must be the wire ephemeral, not the operator key") + } + if !bytes.Equal(delivered.SenderPublicKey, ephemeralKey) { + t.Fatal("delivered sealing key must equal the sender's wire ephemeral key") + } + default: + t.Fatal("expected the round-1 message on the round-1 stream") + } +} + +// TestBroadcastChannelDKGBus_ReplayDeliversAndDedups covers the prebuffer rescue path: +// a round-1 message captured before Start (by the prebuffer, from before the readiness +// barrier) is delivered when Replayed, and Replaying it again - as if it also arrived +// live - is deduplicated by content, so a member never sees the same round-1 twice. +func TestBroadcastChannelDKGBus_ReplayDeliversAndDedups(t *testing.T) { + f := newDKGBusAuthFixture(t, 8) + sub := f.bus.Subscribe(1) + + msg := dkgRound1Msg(1, f.operatorA, "sess", "prebuffered-round1") + f.bus.Replay([]net.Message{msg}) + + select { + case delivered := <-sub.round1: + if string(delivered.Payload) != "prebuffered-round1" { + t.Fatalf("unexpected replayed payload: %q", delivered.Payload) + } + default: + t.Fatal("a replayed round-1 message must be delivered") + } + + // The same message arriving live (or replayed again) must NOT redeliver. + f.bus.Replay([]net.Message{msg}) + select { + case dup := <-sub.round1: + t.Fatalf("a duplicate replay must be deduped, got: %q", dup.Payload) + default: + } +} + +// TestDKGMessagePrebuffer_DrainAndForwardHandsOff covers the race-free handoff: draining +// delivers the already-captured messages to the sink AND installs the sink so any message +// the capture handler catches around the drain instant is forwarded live rather than +// buffered-then-lost. (The handler's forward branch is exercised end-to-end by the +// multi-node e2e; here we pin the DrainAndForward contract deterministically.) +func TestDKGMessagePrebuffer_DrainAndForwardHandsOff(t *testing.T) { + f := newDKGBusAuthFixture(t, 8) + + pb := &DKGMessagePrebuffer{capacity: 10} + pb.messages = []net.Message{dkgRound1Msg(1, f.operatorA, "sess", "buffered-round1")} + + var forwarded []net.Message + pb.DrainAndForward(func(m net.Message) { forwarded = append(forwarded, m) }) + + if len(forwarded) != 1 { + t.Fatalf("the buffered message must be delivered to the sink on handoff; got %d", len(forwarded)) + } + if payload := forwarded[0].Payload().(*dkgTransportMessage).payload; string(payload) != "buffered-round1" { + t.Fatalf("unexpected drained payload: %q", payload) + } + if pb.messages != nil { + t.Fatal("the buffer must be cleared after DrainAndForward") + } + if pb.forward == nil { + t.Fatal("the forward sink must be installed so post-handoff captures are forwarded, not lost") + } + // A capture arriving after handoff goes to the sink (what the Recv handler does). + pb.forward(dkgRound1Msg(2, f.operatorB, "sess", "post-handoff-round1")) + if len(forwarded) != 2 { + t.Fatal("a post-handoff capture must be forwarded to the sink") + } +} + +func TestBroadcastChannelDKGBus_RejectsSpoofedSeat(t *testing.T) { + f := newDKGBusAuthFixture(t, 8) + sub := f.bus.Subscribe(1) + + // Operator B (seat 2) claims seat 1; an outsider claims seat 1; operator A + // claims seat 2 (operator B's). All must be dropped. + f.bus.handleMessage(dkgRound1Msg(1, f.operatorB, "sess", "spoofed")) + f.bus.handleMessage(dkgRound1Msg(1, f.outsider, "sess", "outsider")) + f.bus.handleMessage(dkgRound1Msg(2, f.operatorA, "sess", "claiming-Bs-seat")) + + select { + case msg := <-sub.round1: + t.Fatalf("expected spoofed-seat messages to be dropped, got sender [%d]", msg.Sender) + default: + } +} + +func TestBroadcastChannelDKGBus_MultiSeatOperator(t *testing.T) { + f := newDKGBusAuthFixture(t, 8) + sub := f.bus.Subscribe(1) + + // Operator A holds seats 1 AND 3: it may authentically send as either. + f.bus.handleMessage(dkgRound1Msg(1, f.operatorA, "sess", "from-1")) + f.bus.handleMessage(dkgRound1Msg(3, f.operatorA, "sess", "from-3")) + + got := map[group.MemberIndex]string{} + for { + select { + case msg := <-sub.round1: + got[msg.Sender] = string(msg.Payload) + continue + default: + } + break + } + if len(got) != 2 || got[1] != "from-1" || got[3] != "from-3" { + t.Fatalf("expected both of operator A's seats delivered, got %v", got) + } +} + +func TestBroadcastChannelDKGBus_DedupsByteIdentical(t *testing.T) { + f := newDKGBusAuthFixture(t, 8) + sub := f.bus.Subscribe(1) + + msg := dkgRound1Msg(1, f.operatorA, "sess", "same-body") + f.bus.handleMessage(msg) + f.bus.handleMessage(msg) // an identical retransmission + + count := 0 + for { + select { + case <-sub.round1: + count++ + continue + default: + } + break + } + if count != 1 { + t.Fatalf("byte-identical retransmission must be deduped to a single delivery, got %d", count) + } +} + +func TestBroadcastChannelDKGBus_Round2DeliveredOnlyToRecipient(t *testing.T) { + f := newDKGBusAuthFixture(t, 8) + // This subscriber is seat 1; a round-2 message addressed to seat 2 must NOT + // reach it (that would be the O(n^2) fan-out the recipient filter prevents). + sub := f.bus.Subscribe(1) + + f.bus.handleMessage(dkgRound2Msg(3, 2, f.operatorA, "sess", "for-seat-2")) + + select { + case msg := <-sub.round2: + t.Fatalf("seat 1 received a round-2 message addressed to seat 2: %+v", msg) + default: + } +} + +func TestDKGTransportMessage_MarshalRoundTrip(t *testing.T) { + original := &dkgTransportMessage{ + messageType: dkgRound1Message, + sender: 7, + recipient: 3, + session: "wallet-seed-0xabcd-attempt-2", + ephemeralPublicKey: []byte("per-dkg-ephemeral-sealing-pubkey"), + payload: []byte("sealed-round-2-share-bytes"), + } + encoded, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + decoded := &dkgTransportMessage{messageType: dkgRound1Message} + if err := decoded.Unmarshal(encoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.sender != original.sender || decoded.recipient != original.recipient || + decoded.session != original.session || + !bytes.Equal(decoded.ephemeralPublicKey, original.ephemeralPublicKey) || + !bytes.Equal(decoded.payload, original.payload) { + t.Fatalf("round-trip mismatch: got %+v, want %+v", decoded, original) + } +} + +func TestDKGTransportMessage_UnmarshalRejectsMalformed(t *testing.T) { + // Too short for the header. + if err := (&dkgTransportMessage{}).Unmarshal([]byte{0x00, 0x01}); err == nil { + t.Fatal("expected an error for a truncated header") + } + // Out-of-range sender (0). + zeroSender := make([]byte, 10) + if err := (&dkgTransportMessage{}).Unmarshal(zeroSender); err == nil { + t.Fatal("expected an error for a zero sender") + } + // Session length longer than the remaining bytes. + badSession := make([]byte, 10) + badSession[3] = 1 // sender = 1 + badSession[9] = 20 // session_len = 20, but no session bytes follow + if err := (&dkgTransportMessage{}).Unmarshal(badSession); err == nil { + t.Fatal("expected an error for a truncated session field") + } +} diff --git a/pkg/frost/signing/distributed_dkg_runner_frost_native.go b/pkg/frost/signing/distributed_dkg_runner_frost_native.go new file mode 100644 index 0000000000..5b3b0c3c71 --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_runner_frost_native.go @@ -0,0 +1,624 @@ +//go:build frost_native + +package signing + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/keep-network/keep-core/pkg/crypto/ephemeral" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This file wires the real DISTRIBUTED FROST DKG (three-round Pedersen DKG: +// Part1 -> Part2 -> Part3) into a per-member, bus-driven orchestrator - the shape +// the tBTC node needs. Today the node's wallet-DKG path +// (pkg/tbtc executeTBTCSignerFROSTDKG -> RunDKGWithSeed) is a transitional +// TRUSTED-DEALER key generation (frost::keys::generate_with_dealer seeded by the +// public on-chain seed), which the Rust signer HARD-DISABLES under +// TBTC_SIGNER_PROFILE=production ("production requires distributed DKG wiring"). +// The distributed-DKG crypto already exists (engine Part1/Part2/Part3 over the +// tbtc-signer FFI); what was missing is an orchestrator that exchanges the round +// packages between nodes so each node ends up with its OWN secret share of a +// t-of-n key that NO node ever holds in full. This is that orchestrator, driven +// over a message bus so it can later run over the real wallet broadcast channel. +// +// Round structure (frost-core / RFC-9591), for member m with identifier id_m: +// Round 1: Part1(id_m, n, t) -> a local round-1 SECRET package (never leaves +// the node) + a public round-1 package, BROADCAST to all. Collect the other +// n-1 members' round-1 packages. +// Round 2: Part2(secret1, others' round-1 packages) -> a local round-2 secret +// package + one round-2 package PER OTHER member (each carrying its +// recipient's identifier and secret-share material). Send each to its +// recipient. Collect the n-1 round-2 packages addressed to me. +// Round 3: Part3(secret2, others' round-1 packages, round-2 packages addressed +// to me) -> this node's long-term KeyPackage (its secret share) + the group +// PublicKeyPackage (agreed identically by every honest member). +// +// Round-2 packages carry secret-share material and are delivered confidentially +// per recipient with TWO-SIDED forward secrecy: each share is ECIES-sealed with a +// fresh sender ephemeral to the recipient's per-DKG EPHEMERAL public key (learned +// from the recipient's authenticated round-1 broadcast), and opened with the +// recipient's matching ephemeral private key, which is discarded when the DKG +// ends (see distributed_dkg_round2_seal_frost_native.go). BOTH the sender and +// recipient keys are ephemeral, so - like pkg/tecdsa/dkg's exchange round - an +// observer who records the round-2 broadcasts and LATER obtains members' operator +// keys learns nothing: the sealing keys never existed at rest. The long-lived +// operator key stays with the transport, which authenticates the round-1 message +// (and thus the ephemeral key riding in it). No extra round is needed - the +// ephemeral public key piggybacks on the round-1 broadcast that already exists. + +// distributedDKGEngine is the subset of the native FROST engine the DKG +// orchestrator needs. *buildTaggedTBTCSignerEngine satisfies it. +type distributedDKGEngine interface { + Part1(participantIdentifier string, maxSigners, minSigners uint16) (*NativeFROSTDKGPart1Result, error) + Part2( + secretPackage *NativeFROSTDKGRound1SecretPackage, + round1Packages []*NativeFROSTDKGRound1Package, + ) (*NativeFROSTDKGPart2Result, error) + Part3( + secretPackage *NativeFROSTDKGRound2SecretPackage, + round1Packages []*NativeFROSTDKGRound1Package, + round2Packages []*NativeFROSTDKGRound2Package, + ) (*NativeFROSTDKGResult, error) +} + +// dkgMessageType tags a DKG round message so subscribers route it. +type dkgMessageType int + +const ( + // dkgRound1Message carries a member's public round-1 package (broadcast). + dkgRound1Message dkgMessageType = iota + // dkgRound2Message carries a round-2 package addressed to one recipient. + dkgRound2Message +) + +// dkgMessage is one DKG round message on the bus. Payload is the JSON-encoded +// round-1 or round-2 package; the bus treats it as opaque. +type dkgMessage struct { + Type dkgMessageType + // Session binds a message to ONE DKG attempt. Retries for the same wallet seed + // share a broadcast channel, so without this discriminator a stale round-1 or + // round-2 message from a failed prior attempt (from a retained member) would + // pass the sender/recipient checks, fill that sender's slot in the retry, and + // mix packages across attempts in Part2/Part3. Collectors accept only messages + // carrying the current attempt's session. + Session string + // Sender is the authenticated originating member (the transport binds it; + // the orchestrator never trusts an id inside Payload for routing). + Sender group.MemberIndex + // SenderPublicKey is the sender's per-DKG EPHEMERAL round-2 sealing public key, + // carried on its round-1 message. Peers learn it here and seal this sender's + // round-2 shares to it; the sender opens them with the matching ephemeral + // private key, which is discarded when the DKG ends (recipient-side forward + // secrecy). It is trustworthy because the round-1 message is authenticated + // (the net bus binds the claimed seat to the operator key that signed the + // message, and the ephemeral key rides inside that same authenticated message). + SenderPublicKey []byte + // Recipient is the addressed member for a round-2 message; unused (0) for a + // broadcast round-1 message. + Recipient group.MemberIndex + Payload []byte +} + +// DKGBus is the DKG round-exchange transport. The in-process implementation here +// drives the orchestrator's deterministic tests; production wraps the wallet +// broadcast channel (with round-2 encrypted to the recipient). +type DKGBus interface { + // Broadcast delivers a round-1 message to every subscriber and a round-2 + // message only to the subscriber for its addressed recipient. + Broadcast(msg dkgMessage) + // Subscribe registers a receiver for the given seat up front (before any + // Broadcast); round-2 messages addressed to that seat are routed to it. + Subscribe(member group.MemberIndex) *dkgBusSubscriber + // Start begins delivering inbound messages. It MUST be called only after every + // local seat has Subscribed: a message handled before any subscriber exists is + // dropped, and the transport may have already marked it seen and never + // retransmit it, stalling the DKG. + Start() + // Replay feeds a batch of messages captured by a DKGMessagePrebuffer - which starts + // intaking BEFORE the cross-node readiness barrier - through the receive path, so a + // peer's round-1 broadcast that arrived before this node installed its receiver (the + // window between the barrier releasing peers and Start) is not lost. Call after + // Start and after every seat has Subscribed; messages are deduplicated by content, + // so any that also arrived live are ignored. + Replay(messages []net.Message) + // Deliver feeds ONE message through the receive path. It is the sink a + // DKGMessagePrebuffer forwards to at handoff (DrainAndForward), so a message the + // prebuffer captures around the drain instant is delivered live rather than dropped. + // Deduplicated by content, like Replay. + Deliver(message net.Message) +} + +// dkgBusSubscriber exposes one member's typed round streams. member is the seat +// this subscriber belongs to: round-1 messages are broadcast to every +// subscriber, but a round-2 message - which is ADDRESSED to one recipient - is +// delivered only to the matching subscriber, so a subscriber's round-2 stream +// holds O(n) messages (the ones addressed to it) rather than the O(n^2) whole- +// group fan-out (~9900 for a 100-seat group), which would overflow the buffer. +type dkgBusSubscriber struct { + member group.MemberIndex + round1 chan dkgMessage + round2 chan dkgMessage + + // mu and seen back the net bus's per-subscriber dedup of byte-identical + // retransmissions (keyed by full message content hash); the in-process bus + // leaves them unused. + mu sync.Mutex + seen map[[32]byte]struct{} +} + +// distributedDKGRunner drives one member's participation in one distributed FROST +// DKG over a DKGBus. Every secret (round-1/round-2 secret packages, the final key +// package) stays local to this runner; only the public round-1 package and the +// per-recipient round-2 packages cross the bus. +type distributedDKGRunner struct { + member group.MemberIndex + session string + identifier string + memberIndexes []group.MemberIndex + // memberSet is the DKG participant set as a lookup. A round message from an + // authenticated sender NOT in this set (a shared transport may carry other + // groups' traffic) must be rejected BEFORE it is counted toward a round's + // collection target - otherwise it fills a slot that a real peer's package is + // then dropped from, and the runner proceeds with incomplete packages. + memberSet map[group.MemberIndex]struct{} + identifierByID map[group.MemberIndex]string + idByIdentifier map[string]group.MemberIndex + threshold uint16 + engine distributedDKGEngine + bus DKGBus + sub *dkgBusSubscriber + // recipientKeys is LEARNED during round 1: each member's per-DKG EPHEMERAL + // public key (from the SenderPublicKey on its round-1 message) is the key its + // round-2 share is SEALED to. It is written only by collectRound1 and read only + // by the later round-2 send, both on the single Run goroutine. + recipientKeys map[group.MemberIndex]*ephemeral.PublicKey + // selfKey is this seat's per-DKG EPHEMERAL private key, used to OPEN the round-2 + // shares sealed to us; selfPublicKey is its marshaled ephemeral public key, + // stamped on our broadcasts so peers seal our share to it. The ephemeral key is + // fresh per DKG and discarded afterward, giving recipient-side forward secrecy; + // the operator key stays with the transport, which authenticates the broadcast + // this ephemeral key rides in. + selfKey *ephemeral.PrivateKey + selfPublicKey []byte +} + +func newDistributedDKGRunner( + member group.MemberIndex, + session string, + memberIndexes []group.MemberIndex, + identifierByID map[group.MemberIndex]string, + threshold uint16, + engine distributedDKGEngine, + bus DKGBus, + selfKey *ephemeral.PrivateKey, + selfPublicKey []byte, +) (*distributedDKGRunner, error) { + switch { + case engine == nil: + return nil, fmt.Errorf("distributed dkg: engine is nil") + case bus == nil: + return nil, fmt.Errorf("distributed dkg: bus is nil") + case selfKey == nil: + return nil, fmt.Errorf("distributed dkg: self key is nil") + case len(selfPublicKey) == 0: + return nil, fmt.Errorf("distributed dkg: self public key is empty") + case session == "": + return nil, fmt.Errorf("distributed dkg: session is empty") + case threshold == 0: + return nil, fmt.Errorf("distributed dkg: threshold is zero") + case int(threshold) > len(memberIndexes): + return nil, fmt.Errorf( + "distributed dkg: threshold [%d] exceeds member count [%d]", + threshold, len(memberIndexes), + ) + } + identifier, ok := identifierByID[member] + if !ok { + return nil, fmt.Errorf("distributed dkg: no identifier for member [%d]", member) + } + // Build the participant set and the identifier<->member routing maps from the + // DKG member set only (not the whole identifierByID, which may be broader). + // Identifiers must be distinct across participants, or round-2 routing would + // silently collapse two members onto one; fail closed on a collision. + memberSet := make(map[group.MemberIndex]struct{}, len(memberIndexes)) + idByIdentifier := make(map[string]group.MemberIndex, len(memberIndexes)) + memberInSet := false + for _, m := range memberIndexes { + id, ok := identifierByID[m] + if !ok { + return nil, fmt.Errorf("distributed dkg: no identifier for member [%d] in set", m) + } + if existing, dup := idByIdentifier[id]; dup { + return nil, fmt.Errorf( + "distributed dkg: members [%d] and [%d] share identifier %q", + existing, m, id, + ) + } + idByIdentifier[id] = m + memberSet[m] = struct{}{} + if m == member { + memberInSet = true + } + } + if !memberInSet { + return nil, fmt.Errorf("distributed dkg: member [%d] is not in the member set", member) + } + return &distributedDKGRunner{ + member: member, + session: session, + identifier: identifier, + memberIndexes: memberIndexes, + memberSet: memberSet, + identifierByID: identifierByID, + idByIdentifier: idByIdentifier, + threshold: threshold, + engine: engine, + bus: bus, + recipientKeys: make(map[group.MemberIndex]*ephemeral.PublicKey, len(memberIndexes)), + selfKey: selfKey, + selfPublicKey: selfPublicKey, + // Subscribe at construction so no peer's round-1 broadcast is missed; + // round-2 messages addressed to this seat route here. + sub: bus.Subscribe(member), + }, nil +} + +// Run executes the three DKG rounds for this member and returns its DKG result +// (secret KeyPackage + the group PublicKeyPackage). It honors ctx cancellation +// while collecting each round's packages: a member that never broadcasts stalls +// the round to the deadline, failing into the (existing) DKG retry/challenge +// path rather than hanging forever. +func (r *distributedDKGRunner) Run(ctx context.Context) (*NativeFROSTDKGResult, error) { + n := len(r.memberIndexes) + + // Scrub this seat's per-DKG ephemeral private key when the DKG concludes: it + // exists only to open round-2 shares during this Run, and forward secrecy + // depends on it not lingering at rest (in a later core dump or swap file). + defer r.selfKey.Zero() + + // ---- Round 1: our public package broadcast; secret kept local. ---- + part1, err := r.engine.Part1(r.identifier, uint16(n), r.threshold) + if err != nil { + return nil, fmt.Errorf("distributed dkg: part1: %w", err) + } + if part1.Package == nil || part1.SecretPackage == nil { + return nil, fmt.Errorf("distributed dkg: part1 returned an incomplete result") + } + // Scrub the round-1 secret package on return: Part2 consumes it below, but it + // must not linger in the heap afterward (the engine only zeroes its own + // transport buffers; the copies handed to the Go caller are ours to scrub). + defer zeroBytes(part1.SecretPackage.Data) + if err := r.broadcastPackage(dkgRound1Message, 0, part1.Package); err != nil { + return nil, err + } + round1Packages, err := r.collectRound1(ctx, n-1) + if err != nil { + return nil, err + } + + // ---- Round 2: a package per other member, addressed to its recipient. ---- + part2, err := r.engine.Part2(part1.SecretPackage, round1Packages) + if err != nil { + return nil, fmt.Errorf("distributed dkg: part2: %w", err) + } + if part2.SecretPackage == nil { + return nil, fmt.Errorf("distributed dkg: part2 returned an incomplete result") + } + // Scrub the round-2 secret package AND the OUTGOING per-recipient share + // packages on return: both carry secret material and are consumed only within + // this function (Part3 consumes the RECEIVED packages, not these). Registered + // BEFORE the count check so an unexpected-count return still scrubs them. + defer zeroBytes(part2.SecretPackage.Data) + defer func() { + for _, pkg := range part2.Packages { + if pkg != nil { + zeroBytes(pkg.Data) + } + } + }() + if len(part2.Packages) != n-1 { + return nil, fmt.Errorf( + "distributed dkg: part2 produced [%d] packages, want [%d]", + len(part2.Packages), n-1, + ) + } + for _, pkg := range part2.Packages { + recipient, ok := r.idByIdentifier[pkg.Identifier] + if !ok { + return nil, fmt.Errorf( + "distributed dkg: part2 package addressed to unknown identifier", + ) + } + // Seal the share to the recipient's key - learned from its round-1 message - + // before it crosses the group-visible channel: only the recipient can open + // it. The key is present because we accept a round-1 sender only after + // learning its key, and Part2 produces packages only for round-1 senders. + recipientKey, ok := r.recipientKeys[recipient] + if !ok { + return nil, fmt.Errorf( + "distributed dkg: no learned round-2 sealing key for recipient [%d]", recipient, + ) + } + sealed, err := sealRound2Share(pkg.Data, recipientKey) + if err != nil { + return nil, fmt.Errorf( + "distributed dkg: seal round-2 share for member [%d]: %w", recipient, err, + ) + } + if err := r.broadcastPackage(dkgRound2Message, recipient, sealed); err != nil { + return nil, err + } + } + round2Packages, err := r.collectRound2(ctx, n-1) + if err != nil { + return nil, err + } + // The received round-2 packages carry incoming secret-share material; scrub + // them once Part3 (below) has consumed them. + defer func() { + for _, pkg := range round2Packages { + if pkg != nil { + zeroBytes(pkg.Data) + } + } + }() + + // ---- Round 3: our long-term secret share + the agreed group key. ---- + result, err := r.engine.Part3(part2.SecretPackage, round1Packages, round2Packages) + if err != nil { + return nil, fmt.Errorf("distributed dkg: part3: %w", err) + } + if result.KeyPackage == nil || result.PublicKeyPackage == nil { + return nil, fmt.Errorf("distributed dkg: part3 returned an incomplete result") + } + return result, nil +} + +// broadcastPackage JSON-encodes a round package and broadcasts it on the bus. +func (r *distributedDKGRunner) broadcastPackage( + msgType dkgMessageType, + recipient group.MemberIndex, + pkg any, +) error { + payload, err := json.Marshal(pkg) + if err != nil { + return fmt.Errorf("distributed dkg: marshal package: %w", err) + } + r.bus.Broadcast(dkgMessage{ + Type: msgType, + Session: r.session, + Sender: r.member, + SenderPublicKey: r.selfPublicKey, + Recipient: recipient, + Payload: payload, + }) + return nil +} + +// collectRound1 gathers the other members' public round-1 packages (excluding +// our own), keyed by sender so each member is counted at most once, until `want` +// distinct senders have arrived or ctx expires. The package's embedded identifier +// must match its authenticated sender, so a member cannot smuggle another's slot. +func (r *distributedDKGRunner) collectRound1( + ctx context.Context, + want int, +) ([]*NativeFROSTDKGRound1Package, error) { + bySender := make(map[group.MemberIndex]*NativeFROSTDKGRound1Package, want) + for len(bySender) < want { + select { + case <-ctx.Done(): + return nil, fmt.Errorf( + "distributed dkg: collect round1: got [%d] of [%d] packages: %w", + len(bySender), want, ctx.Err(), + ) + case msg := <-r.sub.round1: + if msg.Session != r.session { + continue // a stale message from a different DKG attempt + } + if msg.Sender == r.member { + continue // never our own echo + } + if _, ok := r.memberSet[msg.Sender]; !ok { + // Not a participant in THIS DKG. A shared transport may carry other + // groups' traffic; counting it would fill a slot a real peer is then + // dropped from, so we would proceed with incomplete packages. + continue + } + if _, have := bySender[msg.Sender]; have { + continue + } + var pkg NativeFROSTDKGRound1Package + if err := json.Unmarshal(msg.Payload, &pkg); err != nil { + continue + } + // Bind the package's own identifier to its authenticated sender: a + // member must not contribute a round-1 package under another's id. + if pkg.Identifier != r.identifierByID[msg.Sender] { + continue + } + // Learn this sender's round-2 sealing key: its per-DKG EPHEMERAL public + // key, carried on (and authenticated with) its round-1 message. A sender + // whose key we cannot parse is skipped, not counted: we could not seal a + // round-2 share to it, so it must not fill a slot. + recipientKey, err := ephemeral.UnmarshalPublicKey(msg.SenderPublicKey) + if err != nil { + continue + } + r.recipientKeys[msg.Sender] = recipientKey + pkgCopy := pkg + bySender[msg.Sender] = &pkgCopy + } + } + return sortedRound1Packages(r.memberIndexes, r.member, bySender), nil +} + +// collectRound2 gathers the round-2 packages ADDRESSED to us, one per other +// member, tagging each with its authenticated sender's identifier (Part3 keys +// round-2 packages by sender). Packages addressed to other recipients are +// ignored here. +func (r *distributedDKGRunner) collectRound2( + ctx context.Context, + want int, +) ([]*NativeFROSTDKGRound2Package, error) { + bySender := make(map[group.MemberIndex]*NativeFROSTDKGRound2Package, want) + for len(bySender) < want { + select { + case <-ctx.Done(): + // Scrub the partial shares accumulated before the deadline: a missing + // round-2 package is an expected dropout/retry path, and each held + // package carries incoming secret-share material. len(bySender) is + // unaffected by zeroing the payloads, so the count below stays accurate. + for _, pkg := range bySender { + zeroBytes(pkg.Data) + } + return nil, fmt.Errorf( + "distributed dkg: collect round2: got [%d] of [%d] packages: %w", + len(bySender), want, ctx.Err(), + ) + case msg := <-r.sub.round2: + if msg.Session != r.session { + continue // a stale message from a different DKG attempt + } + if msg.Sender == r.member || msg.Recipient != r.member { + continue // not addressed to us (or our own echo) + } + if _, ok := r.memberSet[msg.Sender]; !ok { + continue // not a participant in THIS DKG (shared transport) + } + if _, have := bySender[msg.Sender]; have { + continue + } + var sealed sealedRound2Share + if err := json.Unmarshal(msg.Payload, &sealed); err != nil { + continue + } + // Open the share sealed to us (only we can, via our private key). A + // share we cannot open - corrupt, tampered, or sealed to someone else - + // is skipped, not counted: the sender's slot stays empty and the round + // fails into the retry path if a valid one never arrives. + share, err := openRound2Share(&sealed, r.selfKey) + if err != nil { + continue + } + // Reconstruct the round-2 package Part3 consumes: addressed to us (our + // identifier) and tagged with its authenticated sender (Part3 keys + // round-2 packages by sender while the package carries the recipient). + bySender[msg.Sender] = &NativeFROSTDKGRound2Package{ + Identifier: r.identifier, + SenderIdentifier: r.identifierByID[msg.Sender], + Data: share, + } + } + } + return sortedRound2Packages(r.memberIndexes, r.member, bySender), nil +} + +// sortedRound1Packages returns the collected round-1 packages in ascending +// member order (deterministic input to Part2/Part3), excluding our own. +func sortedRound1Packages( + memberIndexes []group.MemberIndex, + self group.MemberIndex, + bySender map[group.MemberIndex]*NativeFROSTDKGRound1Package, +) []*NativeFROSTDKGRound1Package { + out := make([]*NativeFROSTDKGRound1Package, 0, len(bySender)) + for _, m := range memberIndexes { + if m == self { + continue + } + if pkg, ok := bySender[m]; ok { + out = append(out, pkg) + } + } + return out +} + +// sortedRound2Packages returns the round-2 packages addressed to us in ascending +// sender order, excluding our own. +func sortedRound2Packages( + memberIndexes []group.MemberIndex, + self group.MemberIndex, + bySender map[group.MemberIndex]*NativeFROSTDKGRound2Package, +) []*NativeFROSTDKGRound2Package { + out := make([]*NativeFROSTDKGRound2Package, 0, len(bySender)) + for _, m := range memberIndexes { + if m == self { + continue + } + if pkg, ok := bySender[m]; ok { + out = append(out, pkg) + } + } + return out +} + +// inProcessDKGBus is the deterministic in-process DKGBus for orchestrator tests. +// Broadcast delivers synchronously into every subscriber's buffered round +// streams; the buffer is sized above a whole DKG's message volume so an honest +// run never blocks. +type inProcessDKGBus struct { + mu sync.Mutex + subscribers []*dkgBusSubscriber + bufferSize int +} + +// NewInProcessDKGBus returns an in-process DKG bus with per-stream buffers of the +// given size. Sends are blocking, so bufferSize MUST exceed a subscriber's honest +// per-stream volume (round-1: one per member; round-2: one per OTHER member, as +// round-2 is delivered only to its addressed recipient) or a Broadcast can block. +// It is sized generously for the small groups the orchestrator tests use; +// production runs over the net broadcast channel, not this bus. +func NewInProcessDKGBus(bufferSize int) DKGBus { + if bufferSize < 1 { + bufferSize = 1 + } + return &inProcessDKGBus{bufferSize: bufferSize} +} + +func (b *inProcessDKGBus) Subscribe(member group.MemberIndex) *dkgBusSubscriber { + s := &dkgBusSubscriber{ + member: member, + round1: make(chan dkgMessage, b.bufferSize), + round2: make(chan dkgMessage, b.bufferSize), + } + b.mu.Lock() + b.subscribers = append(b.subscribers, s) + b.mu.Unlock() + return s +} + +// Start is a no-op: the in-process bus delivers synchronously in Broadcast, so +// there is no inbound-receive handler to defer. +func (b *inProcessDKGBus) Start() {} + +// Replay is a no-op: the in-process bus has no real transport and no readiness +// barrier, so there is no pre-barrier window to rescue messages from. +func (b *inProcessDKGBus) Replay(_ []net.Message) {} + +// Deliver is a no-op for the same reason: no prebuffer forwards to the in-process bus. +func (b *inProcessDKGBus) Deliver(_ net.Message) {} + +func (b *inProcessDKGBus) Broadcast(msg dkgMessage) { + b.mu.Lock() + subscribers := append([]*dkgBusSubscriber(nil), b.subscribers...) + b.mu.Unlock() + for _, s := range subscribers { + // An addressed round-2 message goes only to its recipient's subscriber. + if msg.Type == dkgRound2Message && s.member != msg.Recipient { + continue + } + // Own the payload per delivery so no receiver can mutate another's view. + delivered := msg + delivered.Payload = append([]byte(nil), msg.Payload...) + switch msg.Type { + case dkgRound1Message: + s.round1 <- delivered + case dkgRound2Message: + s.round2 <- delivered + } + } +} diff --git a/pkg/frost/signing/distributed_dkg_runner_frost_native_test.go b/pkg/frost/signing/distributed_dkg_runner_frost_native_test.go new file mode 100644 index 0000000000..a340dca2ea --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_runner_frost_native_test.go @@ -0,0 +1,315 @@ +//go:build frost_native + +package signing + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/crypto/ephemeral" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const testDKGSession = "dkg-test-session" + +// noopDKGEngine satisfies distributedDKGEngine without touching the FFI, so the +// bus/collection logic is testable without the linked signer lib (no cgo). +type noopDKGEngine struct{} + +func (noopDKGEngine) Part1(string, uint16, uint16) (*NativeFROSTDKGPart1Result, error) { + return nil, nil +} + +func (noopDKGEngine) Part2( + *NativeFROSTDKGRound1SecretPackage, + []*NativeFROSTDKGRound1Package, +) (*NativeFROSTDKGPart2Result, error) { + return nil, nil +} + +func (noopDKGEngine) Part3( + *NativeFROSTDKGRound2SecretPackage, + []*NativeFROSTDKGRound1Package, + []*NativeFROSTDKGRound2Package, +) (*NativeFROSTDKGResult, error) { + return nil, nil +} + +// dkgTestKeys generates a per-member ephemeral keypair standing in for the +// members' operator keys (both secp256k1): the private keys open round-2 shares, +// the marshaled public keys are the "operator public keys" peers learn from +// round-1 messages and seal round-2 shares to. +func dkgTestKeys(t *testing.T, members []group.MemberIndex) ( + map[group.MemberIndex]*ephemeral.PrivateKey, + map[group.MemberIndex][]byte, +) { + t.Helper() + priv := make(map[group.MemberIndex]*ephemeral.PrivateKey, len(members)) + pub := make(map[group.MemberIndex][]byte, len(members)) + for _, m := range members { + kp, err := ephemeral.GenerateKeyPair() + if err != nil { + t.Fatalf("key for member %d: %v", m, err) + } + priv[m] = kp.PrivateKey + pub[m] = kp.PublicKey.Marshal() + } + return priv, pub +} + +// sealTestShareToKey seals a round-2 share for a marshaled recipient key, as the +// orchestrator does before broadcasting. +func sealTestShareToKey(t *testing.T, share []byte, recipientMarshaled []byte) []byte { + t.Helper() + recipient, err := ephemeral.UnmarshalPublicKey(recipientMarshaled) + if err != nil { + t.Fatalf("parse recipient key: %v", err) + } + sealed, err := sealRound2Share(share, recipient) + if err != nil { + t.Fatalf("seal test share: %v", err) + } + payload, err := json.Marshal(sealed) + if err != nil { + t.Fatalf("marshal sealed share: %v", err) + } + return payload +} + +// TestDistributedDKGRunner_CollectRound1RejectsNonParticipants guards the +// shared-transport hazard: a message from an authenticated sender that is NOT in +// THIS DKG's member set must not be counted toward the round's collection target. +// Feeding a non-member FIRST and then the two real peers must still collect both +// real peers' packages. +func TestDistributedDKGRunner_CollectRound1RejectsNonParticipants(t *testing.T) { + members := []group.MemberIndex{1, 2, 3} + identifierByID := map[group.MemberIndex]string{ + 1: "id-1", 2: "id-2", 3: "id-3", 4: "id-4", + } + // Keys for all senders that appear (including non-member 4). + priv, pub := dkgTestKeys(t, []group.MemberIndex{1, 2, 3, 4}) + + bus := NewInProcessDKGBus(16) + runner, err := newDistributedDKGRunner(3, testDKGSession, members, identifierByID, 2, noopDKGEngine{}, bus, priv[3], pub[3]) + if err != nil { + t.Fatalf("new runner: %v", err) + } + + push := func(sender group.MemberIndex) { + payload, err := json.Marshal(NativeFROSTDKGRound1Package{ + Identifier: identifierByID[sender], + Data: []byte{byte(sender)}, + }) + if err != nil { + t.Fatalf("marshal round1 package: %v", err) + } + runner.sub.round1 <- dkgMessage{ + Type: dkgRound1Message, Session: testDKGSession, Sender: sender, SenderPublicKey: pub[sender], Payload: payload, + } + } + push(4) // non-member first + push(1) + push(2) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + got, err := runner.collectRound1(ctx, len(members)-1) // want 2 real peers + if err != nil { + t.Fatalf("collectRound1: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 real-peer round-1 packages, got %d (a non-member was counted early?)", len(got)) + } + seen := map[string]bool{} + for _, pkg := range got { + seen[pkg.Identifier] = true + } + if !seen["id-1"] || !seen["id-2"] || seen["id-4"] { + t.Fatalf("collected the wrong peers: %v", seen) + } + // The real peers' sealing keys must have been learned from their round-1 keys. + if runner.recipientKeys[1] == nil || runner.recipientKeys[2] == nil { + t.Fatalf("expected round-2 sealing keys learned for members 1 and 2") + } +} + +// TestDistributedDKGRunner_CollectRound1IgnoresOtherSessions guards the retry +// hazard: a stale round-1 message from a FAILED prior attempt (a different +// session) sharing the channel must not be counted, so Part2/Part3 never mix +// packages across attempts. Wrong-session messages are ignored -> timeout. +func TestDistributedDKGRunner_CollectRound1IgnoresOtherSessions(t *testing.T) { + members := []group.MemberIndex{1, 2, 3} + identifierByID := map[group.MemberIndex]string{1: "id-1", 2: "id-2", 3: "id-3"} + priv, pub := dkgTestKeys(t, members) + bus := NewInProcessDKGBus(16) + runner, err := newDistributedDKGRunner(3, testDKGSession, members, identifierByID, 2, noopDKGEngine{}, bus, priv[3], pub[3]) + if err != nil { + t.Fatalf("new runner: %v", err) + } + + for _, sender := range []group.MemberIndex{1, 2} { + payload, err := json.Marshal(NativeFROSTDKGRound1Package{Identifier: identifierByID[sender], Data: []byte{byte(sender)}}) + if err != nil { + t.Fatalf("marshal round1 package: %v", err) + } + runner.sub.round1 <- dkgMessage{ + Type: dkgRound1Message, Session: "prior-attempt-session", Sender: sender, SenderPublicKey: pub[sender], Payload: payload, + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if _, err := runner.collectRound1(ctx, len(members)-1); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("stale-session round-1 messages must be ignored (timeout expected); got %v", err) + } +} + +// TestDistributedDKGRunner_CollectRound1SkipsUnparseableKey ensures a round-1 +// sender whose operator key cannot be parsed is skipped (we could not seal a +// round-2 share to it), so it does not fill a slot. +func TestDistributedDKGRunner_CollectRound1SkipsUnparseableKey(t *testing.T) { + members := []group.MemberIndex{1, 2, 3} + identifierByID := map[group.MemberIndex]string{1: "id-1", 2: "id-2", 3: "id-3"} + priv, pub := dkgTestKeys(t, members) + bus := NewInProcessDKGBus(16) + runner, err := newDistributedDKGRunner(3, testDKGSession, members, identifierByID, 2, noopDKGEngine{}, bus, priv[3], pub[3]) + if err != nil { + t.Fatalf("new runner: %v", err) + } + + payload, _ := json.Marshal(NativeFROSTDKGRound1Package{Identifier: identifierByID[1], Data: []byte{1}}) + // Member 1 with a garbage operator key; member 2 with a good one. + runner.sub.round1 <- dkgMessage{Type: dkgRound1Message, Session: testDKGSession, Sender: 1, SenderPublicKey: []byte{0x00, 0x01}, Payload: payload} + payload2, _ := json.Marshal(NativeFROSTDKGRound1Package{Identifier: identifierByID[2], Data: []byte{2}}) + runner.sub.round1 <- dkgMessage{Type: dkgRound1Message, Session: testDKGSession, Sender: 2, SenderPublicKey: pub[2], Payload: payload2} + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if _, err := runner.collectRound1(ctx, len(members)-1); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("a round-1 sender with an unparseable key must be skipped (timeout expected); got %v", err) + } +} + +// TestNewDistributedDKGRunner_RejectsDuplicateIdentifiers guards the routing map. +func TestNewDistributedDKGRunner_RejectsDuplicateIdentifiers(t *testing.T) { + members := []group.MemberIndex{1, 2, 3} + identifierByID := map[group.MemberIndex]string{1: "id-1", 2: "dup", 3: "dup"} + priv, pub := dkgTestKeys(t, members) + bus := NewInProcessDKGBus(16) + if _, err := newDistributedDKGRunner(1, testDKGSession, members, identifierByID, 2, noopDKGEngine{}, bus, priv[1], pub[1]); err == nil { + t.Fatal("expected a duplicate-identifier error, got nil") + } +} + +// TestNewDistributedDKGRunner_FailsClosedOnMissingInputs covers the fail-closed +// construction guards: an empty self public key and an empty session. +func TestNewDistributedDKGRunner_FailsClosedOnMissingInputs(t *testing.T) { + members := []group.MemberIndex{1, 2, 3} + identifierByID := map[group.MemberIndex]string{1: "id-1", 2: "id-2", 3: "id-3"} + bus := NewInProcessDKGBus(16) + priv, pub := dkgTestKeys(t, members) + + if _, err := newDistributedDKGRunner(1, testDKGSession, members, identifierByID, 2, noopDKGEngine{}, bus, priv[1], nil); err == nil { + t.Fatal("expected an empty self-public-key error, got nil") + } + if _, err := newDistributedDKGRunner(1, "", members, identifierByID, 2, noopDKGEngine{}, bus, priv[1], pub[1]); err == nil { + t.Fatal("expected an empty-session error, got nil") + } +} + +// TestDistributedDKGRunner_CollectRound2RejectsNonParticipants is the round-2 +// analogue of the round-1 guard. +func TestDistributedDKGRunner_CollectRound2RejectsNonParticipants(t *testing.T) { + members := []group.MemberIndex{1, 2, 3} + identifierByID := map[group.MemberIndex]string{ + 1: "id-1", 2: "id-2", 3: "id-3", 4: "id-4", + } + priv, pub := dkgTestKeys(t, []group.MemberIndex{1, 2, 3, 4}) + bus := NewInProcessDKGBus(16) + // self = 3; it collects round-2 shares addressed to it from members 1, 2. + runner, err := newDistributedDKGRunner(3, testDKGSession, members, identifierByID, 2, noopDKGEngine{}, bus, priv[3], pub[3]) + if err != nil { + t.Fatalf("new runner: %v", err) + } + + push := func(sender group.MemberIndex) { + payload := sealTestShareToKey(t, []byte{byte(sender)}, pub[3]) // sealed to us + runner.sub.round2 <- dkgMessage{ + Type: dkgRound2Message, Session: testDKGSession, Sender: sender, Recipient: 3, Payload: payload, + } + } + push(4) // non-member addressed to us first + push(1) + push(2) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + got, err := runner.collectRound2(ctx, len(members)-1) // want 2 real peers + if err != nil { + t.Fatalf("collectRound2: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 real-peer round-2 packages, got %d (a non-member was counted early?)", len(got)) + } + seen := map[string]bool{} + for _, pkg := range got { + seen[pkg.SenderIdentifier] = true + } + if !seen["id-1"] || !seen["id-2"] || seen["id-4"] { + t.Fatalf("collected the wrong senders: %v", seen) + } +} + +// TestDistributedDKGRunner_CollectRound2SkipsUnopenableShare pins the +// bad-share-into-retry contract: a round-2 message addressed to us but sealed to +// a DIFFERENT key cannot be opened, so it is skipped (timeout), not accepted. +func TestDistributedDKGRunner_CollectRound2SkipsUnopenableShare(t *testing.T) { + members := []group.MemberIndex{1, 2, 3} + identifierByID := map[group.MemberIndex]string{1: "id-1", 2: "id-2", 3: "id-3"} + priv, pub := dkgTestKeys(t, members) + bus := NewInProcessDKGBus(16) + runner, err := newDistributedDKGRunner(3, testDKGSession, members, identifierByID, 2, noopDKGEngine{}, bus, priv[3], pub[3]) + if err != nil { + t.Fatalf("new runner: %v", err) + } + + // Sealed to member 1's key (NOT ours) but routed to us: we cannot open it. + runner.sub.round2 <- dkgMessage{ + Type: dkgRound2Message, Session: testDKGSession, Sender: 2, Recipient: 3, Payload: sealTestShareToKey(t, []byte{2}, pub[1]), + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if _, err := runner.collectRound2(ctx, 1); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("an unopenable share must be skipped (timeout expected); got %v", err) + } +} + +// TestDistributedDKGRunner_CollectRound2TimesOutOnMissingPackage pins the +// fail-into-retry contract for a missing round-2 package. +func TestDistributedDKGRunner_CollectRound2TimesOutOnMissingPackage(t *testing.T) { + members := []group.MemberIndex{1, 2, 3} + identifierByID := map[group.MemberIndex]string{1: "id-1", 2: "id-2", 3: "id-3"} + priv, pub := dkgTestKeys(t, members) + bus := NewInProcessDKGBus(16) + runner, err := newDistributedDKGRunner(3, testDKGSession, members, identifierByID, 2, noopDKGEngine{}, bus, priv[3], pub[3]) + if err != nil { + t.Fatalf("new runner: %v", err) + } + + // Only ONE of the two expected round-2 shares arrives. + runner.sub.round2 <- dkgMessage{ + Type: dkgRound2Message, Session: testDKGSession, Sender: 1, Recipient: 3, Payload: sealTestShareToKey(t, []byte{1}, pub[3]), + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if _, err := runner.collectRound2(ctx, len(members)-1); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("want a deadline-exceeded error when a round-2 package never arrives; got %v", err) + } +} diff --git a/pkg/frost/signing/distributed_dkg_runner_real_cgo_frost_native_test.go b/pkg/frost/signing/distributed_dkg_runner_real_cgo_frost_native_test.go new file mode 100644 index 0000000000..0ff6f92e57 --- /dev/null +++ b/pkg/frost/signing/distributed_dkg_runner_real_cgo_frost_native_test.go @@ -0,0 +1,208 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This test proves the FIRST increment of the distributed-DKG wiring +// (distributed_dkg_runner_frost_native.go): a per-member, BUS-DRIVEN orchestrator +// drives the real three-round FROST DKG so that n nodes, each contributing their +// own secret entropy and exchanging only round packages over a message bus, end +// up agreeing on ONE group key while each holds a DISTINCT secret share that no +// node ever saw in full. This is the shape the tBTC node needs to replace the +// transitional trusted-dealer keygen (RunDKGWithSeed), which the Rust signer +// hard-disables in production ("production requires distributed DKG wiring"). +// +// It is the message-passing analogue of the inline single-goroutine DKG in +// native_frost_engine_..._test.go: the crypto (Part1/Part2/Part3) is identical, +// but here each seat is an independent goroutine that only learns its peers' +// contributions through the bus - so a passing run exercises the ORCHESTRATION +// (round-1 broadcast, round-2 per-recipient routing, round collection), not just +// the engine. Part3 cryptographically verifies each round-2 package against its +// sender's round-1 commitment, so a misrouted or dropped package fails the run; +// a passing run therefore proves the routing is correct. The final threshold +// signature, verified against the group key, proves the shares are a real, +// functional t-of-n key rather than unrelated per-node material. + +func TestDistributedDKGRunner_ThreeSeatsAgreeOnGroupKeyWithDistinctShares(t *testing.T) { + setupRealCgoSignerState(t) + + const n = 3 + const threshold uint16 = 2 + members := []group.MemberIndex{1, 2, 3} + + engine := &buildTaggedTBTCSignerEngine{} + + // Deterministic per-member FROST identifiers (byte-0 = member index), matching + // the scheme the engine's DKG tests use. Production supplies identifiers + // aligned with the signing path; the orchestrator takes them as input. + identifiers := make(map[group.MemberIndex]string, n) + for _, m := range members { + identifiers[m] = buildTaggedTBTCSignerTestIdentifier(byte(m)) + } + + bus := NewInProcessDKGBus(64) + + // Per-member keys standing in for operator keys: each seat stamps its public + // key on round 1, peers LEARN it, and round-2 shares are sealed to it and + // opened with the seat's own private key - the full encrypted round-2 path. + priv, pub := dkgTestKeys(t, members) + + // Build every runner (each subscribes to the bus) BEFORE starting any, so no + // peer's round-1 broadcast is missed. + runners := make(map[group.MemberIndex]*distributedDKGRunner, n) + for _, m := range members { + runner, err := newDistributedDKGRunner(m, testDKGSession, members, identifiers, threshold, engine, bus, priv[m], pub[m]) + if err != nil { + t.Fatalf("new runner (member %d): %v", m, err) + } + runners[m] = runner + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + type seatOutcome struct { + result *NativeFROSTDKGResult + err error + } + outcomes := make(map[group.MemberIndex]*seatOutcome, n) + var mu sync.Mutex + var wg sync.WaitGroup + for _, m := range members { + m := m + wg.Add(1) + go func() { + defer wg.Done() + result, err := runners[m].Run(ctx) + mu.Lock() + outcomes[m] = &seatOutcome{result: result, err: err} + mu.Unlock() + }() + } + wg.Wait() + + // Skip cleanly if the linked signer lib is unavailable (matches the sibling + // real-cgo tests); any other failure is real. + for _, m := range members { + if errors.Is(outcomes[m].err, ErrNativeCryptographyUnavailable) { + t.Skip("linked tbtc-signer FFI symbols unavailable") + } + } + for _, m := range members { + if outcomes[m].err != nil { + t.Fatalf("member %d distributed DKG failed: %v", m, outcomes[m].err) + } + if outcomes[m].result == nil || outcomes[m].result.KeyPackage == nil || + outcomes[m].result.PublicKeyPackage == nil { + t.Fatalf("member %d produced an incomplete DKG result", m) + } + } + + // ---- All seats agree on ONE group key. ---- + groupKey := outcomes[members[0]].result.PublicKeyPackage.VerifyingKey + if len(groupKey) != 64 { + t.Fatalf("group verifying key must be a 32-byte x-only key (64 hex chars); got %d chars", len(groupKey)) + } + for _, m := range members { + pkp := outcomes[m].result.PublicKeyPackage + if pkp.VerifyingKey != groupKey { + t.Fatalf("member %d disagreed on the group key: %s != %s", m, pkp.VerifyingKey, groupKey) + } + if len(pkp.VerifyingShares) != n { + t.Fatalf("member %d has %d verifying shares, want %d", m, len(pkp.VerifyingShares), n) + } + } + + // ---- Each seat holds a DISTINCT secret share bound to its own identifier + // (no dealer broadcasting one key). Because each seat only ran Part1/2/3 with + // its OWN secret packages plus bus-received packages, no seat ever held the + // full secret - the distributed property, by construction. ---- + for _, m := range members { + kp := outcomes[m].result.KeyPackage + if kp.Identifier != identifiers[m] { + t.Fatalf("member %d key package identifier = %q, want %q", m, kp.Identifier, identifiers[m]) + } + if len(kp.Data) == 0 { + t.Fatalf("member %d has an empty key package", m) + } + } + for i := 0; i < len(members); i++ { + for j := i + 1; j < len(members); j++ { + a, b := members[i], members[j] + if bytes.Equal(outcomes[a].result.KeyPackage.Data, outcomes[b].result.KeyPackage.Data) { + t.Fatalf("members %d and %d hold identical key packages; shares must be distinct", a, b) + } + } + } + t.Logf("3 seats agreed on group key %s… with 3 distinct shares (bus-orchestrated distributed DKG)", groupKey[:16]) + + // ---- GOLD: a real t-of-n threshold signature over the DKG output verifies + // against the group key, proving the bus-orchestrated shares are a functional + // threshold key (not just distinct blobs). ---- + signers := members[:threshold] // any t of the n + message := bytesOf(0x42, 32) + + commitments := make([]nativeFROSTCommitment, 0, len(signers)) + noncesBySigner := make(map[group.MemberIndex][]byte, len(signers)) + for _, m := range signers { + kp := outcomes[m].result.KeyPackage + nonces, commitmentIdentifier, commitmentData, err := engine.GenerateNoncesAndCommitments(kp.Identifier, kp.Data) + if err != nil { + t.Fatalf("member %d nonce generation: %v", m, err) + } + commitments = append(commitments, nativeFROSTCommitment{Identifier: commitmentIdentifier, Data: commitmentData}) + noncesBySigner[m] = nonces + } + + signingPackage, err := engine.NewSigningPackage(message, commitments) + if err != nil { + t.Fatalf("new signing package: %v", err) + } + + shares := make([]nativeFROSTSignatureShare, 0, len(signers)) + for _, m := range signers { + kp := outcomes[m].result.KeyPackage + shareIdentifier, shareData, err := engine.Sign(signingPackage, noncesBySigner[m], kp.Identifier, kp.Data) + if err != nil { + t.Fatalf("member %d sign: %v", m, err) + } + shares = append(shares, nativeFROSTSignatureShare{Identifier: shareIdentifier, Data: shareData}) + } + + signatureBytes, err := engine.Aggregate(signingPackage, shares, outcomes[members[0]].result.PublicKeyPackage) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + if len(signatureBytes) != 64 { + t.Fatalf("aggregate signature length = %d, want 64", len(signatureBytes)) + } + + groupKeyBytes, err := hex.DecodeString(groupKey) + if err != nil { + t.Fatalf("decode group key: %v", err) + } + publicKey, err := schnorr.ParsePubKey(groupKeyBytes) + if err != nil { + t.Fatalf("parse group key: %v", err) + } + signature, err := schnorr.ParseSignature(signatureBytes) + if err != nil { + t.Fatalf("parse signature: %v", err) + } + if !signature.Verify(message, publicKey) { + t.Fatal("threshold signature does not verify under the distributed-DKG group key") + } + t.Logf("threshold signature by %d-of-%d verifies under the distributed-DKG group key", threshold, n) +} diff --git a/pkg/frost/signing/dkg_group_pubkey_extraction.go b/pkg/frost/signing/dkg_group_pubkey_extraction.go new file mode 100644 index 0000000000..db4b3880ae --- /dev/null +++ b/pkg/frost/signing/dkg_group_pubkey_extraction.go @@ -0,0 +1,187 @@ +//go:build frost_native + +package signing + +import ( + "encoding/hex" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/keep-network/keep-core/pkg/frost" +) + +// ErrUnsupportedSignerMaterialFormat is returned by +// ExtractDkgGroupPublicKeyFromMaterial when the material's Format +// field names a signer-material variant the helper cannot extract a DKG group +// public key from. The current implementation accepts FrostTBTCSignerV1; +// FrostUniFFIV1 is rejected because the legacy bridge format does not expose +// the group key, and unsupported FrostUniFFIV2 material is rejected because it +// cannot support Taproot-tweaked deposit sweep signatures. +// +// Per RFC-21 Phase-6 Resolved Decision: the Phase 7 manifest flip +// is gated on verified migration off V1 across production signers, +// so this error class is expected to disappear by the time ROAST +// retry ships unconditionally. +var ErrUnsupportedSignerMaterialFormat = errors.New( + "dkg group public key: unsupported signer-material format for extraction", +) + +// ExtractDkgGroupPublicKeyFromMaterial returns the DKG-validated +// group public key from the supplied NativeSignerMaterial in the +// canonical byte representation that attempt.DeriveAttemptSeed +// consumes. Two honest signers feeding the same material into this +// helper produce byte-identical outputs. +// +// Format handling: +// +// - FrostTBTCSignerV1: decode payload as NativeTBTCSignerMaterialPayload; +// return the raw bytes of the KeyGroup identifier. The tbtc-signer +// engine treats KeyGroup as the canonical handle for the FROST +// key group; every honest signer running the same tbtc-signer +// build agrees on its bytes. +// +// - FrostUniFFIV1 and FrostUniFFIV2: return +// ErrUnsupportedSignerMaterialFormat. V1 material is the legacy bridge +// format that does not carry the group key in a form Phase 6 can extract. +// V2 material is unsupported in favor of FrostTBTCSignerV1. +// +// Callers MUST use the returned bytes only as the +// DkgGroupPublicKey input to attempt.DeriveAttemptSeed; the bytes +// are not interchangeable across format boundaries. Production signing groups +// must use FrostTBTCSignerV1 material. +func ExtractDkgGroupPublicKeyFromMaterial( + signerMaterial *NativeSignerMaterial, +) ([]byte, error) { + if signerMaterial == nil { + return nil, fmt.Errorf( + "dkg group public key: signer material is nil", + ) + } + switch signerMaterial.Format { + case NativeSignerMaterialFormatFrostTBTCSignerV1: + return extractDkgGroupPublicKeyFromTBTCSignerV1(signerMaterial) + case NativeSignerMaterialFormatFrostUniFFIV1: + return nil, fmt.Errorf( + "%w: %s (migrate to %s before enabling ROAST retry)", + ErrUnsupportedSignerMaterialFormat, + signerMaterial.Format, + NativeSignerMaterialFormatFrostTBTCSignerV1, + ) + case NativeSignerMaterialFormatFrostUniFFIV2: + return nil, fmt.Errorf( + "%w: %s is unsupported; use %s", + ErrUnsupportedSignerMaterialFormat, + signerMaterial.Format, + NativeSignerMaterialFormatFrostTBTCSignerV1, + ) + default: + return nil, fmt.Errorf( + "%w: unknown format %q", + ErrUnsupportedSignerMaterialFormat, + signerMaterial.Format, + ) + } +} + +// ExtractTaprootOutputKeyFromMaterial returns the 32-byte x-only Taproot +// output key committed to by native FROST signer material. +func ExtractTaprootOutputKeyFromMaterial( + signerMaterial *NativeSignerMaterial, +) ([]byte, error) { + if signerMaterial == nil { + return nil, fmt.Errorf("taproot output key: signer material is nil") + } + + switch signerMaterial.Format { + case NativeSignerMaterialFormatFrostTBTCSignerV1: + return extractTaprootOutputKeyFromTBTCSignerV1(signerMaterial) + default: + return nil, fmt.Errorf( + "taproot output key: unsupported signer-material format [%s]", + signerMaterial.Format, + ) + } +} + +func extractDkgGroupPublicKeyFromTBTCSignerV1( + signerMaterial *NativeSignerMaterial, +) ([]byte, error) { + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) + if err != nil { + return nil, fmt.Errorf( + "dkg group public key: decode FrostTBTCSignerV1: %w", + err, + ) + } + if payload.KeyGroup == "" { + return nil, fmt.Errorf( + "dkg group public key: FrostTBTCSignerV1 key group is empty", + ) + } + return []byte(payload.KeyGroup), nil +} + +func extractTaprootOutputKeyFromTBTCSignerV1( + signerMaterial *NativeSignerMaterial, +) ([]byte, error) { + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) + if err != nil { + return nil, fmt.Errorf( + "taproot output key: decode FrostTBTCSignerV1: %w", + err, + ) + } + if payload.KeyGroupSource != NativeTBTCSignerKeyGroupSourceDKGPersisted { + return nil, fmt.Errorf( + "taproot output key: FrostTBTCSignerV1 key group source [%s] is not [%s]", + payload.KeyGroupSource, + NativeTBTCSignerKeyGroupSourceDKGPersisted, + ) + } + + outputKeyHex := payload.TaprootOutputKey + if outputKeyHex == "" { + outputKeyHex = payload.KeyGroup + } + + outputKey, err := TaprootOutputKeyFromTBTCSignerKey(outputKeyHex) + if err != nil { + return nil, fmt.Errorf( + "taproot output key: FrostTBTCSignerV1 key material is invalid: %w", + err, + ) + } + + return outputKey, nil +} + +// TaprootOutputKeyFromTBTCSignerKey converts tbtc-signer key material to the +// x-only BIP-340 output key committed to by P2TR wallet scripts. Current +// tbtc-signer DKG results expose the group verifying key as a compressed +// secp256k1 key-group handle, while older test material may already carry the +// x-only key. +func TaprootOutputKeyFromTBTCSignerKey(keyHex string) ([]byte, error) { + raw, err := hex.DecodeString(keyHex) + if err != nil { + return nil, err + } + + switch len(raw) { + case frost.OutputKeySize: + return raw, nil + case 1 + frost.OutputKeySize: + publicKey, err := btcec.ParsePubKey(raw) + if err != nil { + return nil, err + } + return publicKey.X().FillBytes(make([]byte, frost.OutputKeySize)), nil + default: + return nil, fmt.Errorf( + "must be %d-byte x-only or %d-byte compressed key, got %d bytes", + frost.OutputKeySize, + 1+frost.OutputKeySize, + len(raw), + ) + } +} diff --git a/pkg/frost/signing/dkg_group_pubkey_extraction_test.go b/pkg/frost/signing/dkg_group_pubkey_extraction_test.go new file mode 100644 index 0000000000..13525bea2b --- /dev/null +++ b/pkg/frost/signing/dkg_group_pubkey_extraction_test.go @@ -0,0 +1,238 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestExtractDkgGroupPublicKey_RejectsNilMaterial(t *testing.T) { + _, err := ExtractDkgGroupPublicKeyFromMaterial(nil) + if err == nil { + t.Fatal("expected error for nil material") + } +} + +func TestExtractDkgGroupPublicKey_FrostUniFFIV2_ReturnsUnsupportedSentinel(t *testing.T) { + payload := unsupportedUniFFIV2Payload( + t, + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + ) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + } + _, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if !errors.Is(err, ErrUnsupportedSignerMaterialFormat) { + t.Fatalf("expected ErrUnsupportedSignerMaterialFormat, got %v", err) + } + if !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("error must mention unsupported format; got %v", err) + } +} + +func TestExtractTaprootOutputKey_FrostUniFFIV2_ReturnsUnsupported(t *testing.T) { + payload := unsupportedUniFFIV2Payload( + t, + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + ) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + } + + _, err := ExtractTaprootOutputKeyFromMaterial(mat) + if err == nil { + t.Fatal("expected unsupported V2 taproot output key rejection") + } + if !strings.Contains(err.Error(), "unsupported signer-material format") { + t.Fatalf("error must mention unsupported format; got %v", err) + } +} + +func TestExtractDkgGroupPublicKey_FrostTBTCSignerV1_ReturnsKeyGroupBytes(t *testing.T) { + const keyGroup = "group-A" + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: keyGroup, + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + got, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if err != nil { + t.Fatalf("extract: %v", err) + } + if string(got) != keyGroup { + t.Fatalf("got %q, want %q", string(got), keyGroup) + } +} + +func TestExtractTaprootOutputKey_FrostTBTCSignerV1_DKGPersistedHexDecodes( + t *testing.T, +) { + const hexKey = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: hexKey, + KeyGroupSource: NativeTBTCSignerKeyGroupSourceDKGPersisted, + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + + got, err := ExtractTaprootOutputKeyFromMaterial(mat) + if err != nil { + t.Fatalf("extract: %v", err) + } + want, _ := hex.DecodeString(hexKey) + if !bytes.Equal(got, want) { + t.Fatalf( + "taproot output key mismatch: got %x, want %x", + got, + want, + ) + } +} + +func TestExtractTaprootOutputKey_FrostTBTCSignerV1_DKGPersistedCompressedKeyGroup( + t *testing.T, +) { + const compressedKey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + const xOnlyKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: compressedKey, + KeyGroupSource: NativeTBTCSignerKeyGroupSourceDKGPersisted, + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + + got, err := ExtractTaprootOutputKeyFromMaterial(mat) + if err != nil { + t.Fatalf("extract: %v", err) + } + want, _ := hex.DecodeString(xOnlyKey) + if !bytes.Equal(got, want) { + t.Fatalf( + "taproot output key mismatch: got %x, want %x", + got, + want, + ) + } +} + +func TestExtractTaprootOutputKey_FrostTBTCSignerV1_DKGPersistedUsesExplicitOutputKey( + t *testing.T, +) { + const compressedKey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + const outputKey = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: compressedKey, + TaprootOutputKey: outputKey, + KeyGroupSource: NativeTBTCSignerKeyGroupSourceDKGPersisted, + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + + got, err := ExtractTaprootOutputKeyFromMaterial(mat) + if err != nil { + t.Fatalf("extract: %v", err) + } + want, _ := hex.DecodeString(outputKey) + if !bytes.Equal(got, want) { + t.Fatalf( + "taproot output key mismatch: got %x, want %x", + got, + want, + ) + } +} + +func TestExtractTaprootOutputKey_FrostTBTCSignerV1_RejectsNonDKGSource( + t *testing.T, +) { + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: strings.Repeat("11", 32), + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + + _, err := ExtractTaprootOutputKeyFromMaterial(mat) + if err == nil { + t.Fatal("expected non-DKG source rejection") + } + if !strings.Contains(err.Error(), NativeTBTCSignerKeyGroupSourceDKGPersisted) { + t.Fatalf("error should mention persisted DKG source: [%v]", err) + } +} + +func TestExtractDkgGroupPublicKey_FrostTBTCSignerV1_DeterministicAcrossCalls(t *testing.T) { + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "deterministic-group", + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + a, _ := ExtractDkgGroupPublicKeyFromMaterial(mat) + b, _ := ExtractDkgGroupPublicKeyFromMaterial(mat) + if !bytes.Equal(a, b) { + t.Fatalf("extraction is non-deterministic: %x vs %x", a, b) + } +} + +func TestExtractDkgGroupPublicKey_FrostTBTCSignerV1_RejectsEmptyKeyGroup(t *testing.T) { + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "", + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + _, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if err == nil { + t.Fatal("expected error for empty KeyGroup") + } +} + +func TestExtractDkgGroupPublicKey_FrostUniFFIV1_ReturnsUnsupportedSentinel(t *testing.T) { + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte("{}"), + } + _, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if !errors.Is(err, ErrUnsupportedSignerMaterialFormat) { + t.Fatalf("expected ErrUnsupportedSignerMaterialFormat, got %v", err) + } + if !strings.Contains(err.Error(), "migrate to") { + t.Fatalf("error must guide operator to migration; got %v", err) + } +} + +func TestExtractDkgGroupPublicKey_UnknownFormat_ReturnsUnsupportedSentinel(t *testing.T) { + mat := &NativeSignerMaterial{ + Format: "frost-some-future-format-v0", + Payload: []byte("{}"), + } + _, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if !errors.Is(err, ErrUnsupportedSignerMaterialFormat) { + t.Fatalf("expected ErrUnsupportedSignerMaterialFormat, got %v", err) + } + if !strings.Contains(err.Error(), "frost-some-future-format-v0") { + t.Fatalf("error must mention the unknown format; got %v", err) + } +} diff --git a/pkg/frost/signing/evidence_overflow.go b/pkg/frost/signing/evidence_overflow.go new file mode 100644 index 0000000000..60bbe89cc5 --- /dev/null +++ b/pkg/frost/signing/evidence_overflow.go @@ -0,0 +1,43 @@ +//go:build frost_native + +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// senderIndexedMessage is the minimal contract a protocol message must +// satisfy for enqueueOrRecordOverflow to handle it: the message must +// expose its sender so the recorder can attribute overflow events to a +// specific member. +type senderIndexedMessage interface { + SenderID() group.MemberIndex +} + +// enqueueOrRecordOverflow attempts to enqueue payload onto target. If +// the channel is full, the overflow is recorded against the payload's +// sender on the supplied recorder instead. Returns true if the payload +// was enqueued, false if the overflow was recorded. +// +// This is the shared select-or-record body that replaces the three +// inline select { default } drop sites in the FROST/tbtc-signer +// receive loops. Pulling it out lets the recorder integration be unit- +// tested directly without spinning up a network channel. +// +// Phase 2 callers pass attempt.NoOpRecorder(), so behaviour is +// observably unchanged from before RFC-21 wiring. A coordinator-aware +// caller in a later phase injects a real recorder. +func enqueueOrRecordOverflow[T senderIndexedMessage]( + payload T, + target chan<- T, + recorder attempt.EvidenceRecorder, +) bool { + select { + case target <- payload: + return true + default: + recorder.RecordOverflow(payload.SenderID()) + return false + } +} diff --git a/pkg/frost/signing/evidence_overflow_test.go b/pkg/frost/signing/evidence_overflow_test.go new file mode 100644 index 0000000000..15a70a48e6 --- /dev/null +++ b/pkg/frost/signing/evidence_overflow_test.go @@ -0,0 +1,126 @@ +//go:build frost_native + +package signing + +import ( + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestEnqueueOrRecordOverflow_EnqueuesWhenChannelHasRoom(t *testing.T) { + ch := make(chan *buildTaggedTBTCSignerRoundContributionMessage, 4) + rec := attempt.NewBoundedRecorder() + payload := &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 1} + + if !enqueueOrRecordOverflow(payload, ch, rec) { + t.Fatal("enqueue should succeed when channel has room") + } + if got := rec.Snapshot().Overflows[1]; got != 0 { + t.Fatalf("no overflow expected on successful enqueue; got %d", got) + } + if len(ch) != 1 { + t.Fatalf("channel length expected 1, got %d", len(ch)) + } +} + +func TestEnqueueOrRecordOverflow_RecordsOverflowWhenChannelIsFull(t *testing.T) { + ch := make(chan *buildTaggedTBTCSignerRoundContributionMessage, 1) + ch <- &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 99} // fill it + rec := attempt.NewBoundedRecorder() + + payload := &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 7} + if enqueueOrRecordOverflow(payload, ch, rec) { + t.Fatal("enqueue should fail when channel is full") + } + if got := rec.Snapshot().Overflows[7]; got != 1 { + t.Fatalf( + "overflow should be recorded against sender 7; got count %d", + got, + ) + } + if got := rec.Snapshot().Overflows[99]; got != 0 { + t.Fatal( + "sender 99 is the pre-filled payload's sender, not the overflow sender", + ) + } +} + +func TestEnqueueOrRecordOverflow_NoOpRecorderHasNoObservableEffect(t *testing.T) { + ch := make(chan *buildTaggedTBTCSignerRoundContributionMessage, 1) + ch <- &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 1} + rec := attempt.NoOpRecorder() + + payload := &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 7} + if enqueueOrRecordOverflow(payload, ch, rec) { + t.Fatal("enqueue should fail when channel is full") + } + if got := rec.Snapshot().Overflows[7]; got != 0 { + t.Fatalf( + "NoOp recorder must show zero overflow count even when called; got %d", + got, + ) + } +} + +func TestEnqueueOrRecordOverflow_RepeatedOverflowsSaturateAtQuota(t *testing.T) { + ch := make(chan *buildTaggedTBTCSignerRoundContributionMessage, 1) + ch <- &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 1} + rec := attempt.NewBoundedRecorderWithQuota(3) + + for i := 0; i < 10; i++ { + _ = enqueueOrRecordOverflow( + &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 2}, + ch, + rec, + ) + } + if got := rec.Snapshot().Overflows[2]; got != 3 { + t.Fatalf("expected saturation at quota 3, got %d", got) + } +} + +func TestEnqueueOrRecordOverflow_ConcurrentCallersAreRaceSafe(t *testing.T) { + const numProducers = 8 + const recordsPerProducer = 100 + ch := make(chan *buildTaggedTBTCSignerRoundContributionMessage, 1) + ch <- &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 1} // fill it once + rec := attempt.NewBoundedRecorderWithQuota(uint(numProducers * recordsPerProducer)) + + var wg sync.WaitGroup + for p := 0; p < numProducers; p++ { + wg.Add(1) + sender := group.MemberIndex(p + 2) + go func() { + defer wg.Done() + for i := 0; i < recordsPerProducer; i++ { + _ = enqueueOrRecordOverflow( + &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: uint32(sender)}, + ch, + rec, + ) + } + }() + } + wg.Wait() + + snap := rec.Snapshot() + totalRecorded := uint(0) + for _, v := range snap.Overflows { + totalRecorded += v + } + // Every producer's records either enqueued (replacing previously- + // dequeued items, but there's no consumer here so the channel stays + // full and all subsequent enqueue attempts fall to the default + // branch) or recorded. Since the channel starts pre-filled and has + // no consumer, all 800 records hit the overflow path. + const expected = numProducers * recordsPerProducer + if totalRecorded != expected { + t.Fatalf( + "concurrent overflow count: got %d, want %d (sum across senders)", + totalRecorded, expected, + ) + } +} diff --git a/pkg/frost/signing/legacy_backend.go b/pkg/frost/signing/legacy_backend.go new file mode 100644 index 0000000000..a1d4968612 --- /dev/null +++ b/pkg/frost/signing/legacy_backend.go @@ -0,0 +1,101 @@ +package signing + +import ( + "context" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + legacySigning "github.com/keep-network/keep-core/pkg/tecdsa/signing" +) + +const legacyExecutionBackendName = "legacy-tecdsa-bridge" + +type legacyExecutionBackend struct{} + +func newLegacyExecutionBackend() *legacyExecutionBackend { + return &legacyExecutionBackend{} +} + +func (leb *legacyExecutionBackend) Name() string { + return legacyExecutionBackendName +} + +func (leb *legacyExecutionBackend) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if InteractiveSigningOnlyEnabled() { + // Interactive-only mode (coarse-path retirement): the legacy backend is the + // tECDSA/coarse signer the flag retires. Refuse at the action itself so the + // safety switch holds even under the DEFAULT backend selection, where the + // native bridge/adapter guards never run. Terminal so the retry loop aborts. + recordCoarseFallbackRefused() + return nil, fmt.Errorf( + "%w: interactive-only signing mode (%s) is set but the legacy (coarse "+ + "tECDSA) backend is selected; the coarse path is retired", + ErrTerminalSigningFailure, InteractiveSigningOnlyEnvVar, + ) + } + + if request.Attempt != nil { + logger.Infof( + "[member:%v] executing FROST signing attempt [%v] "+ + "with coordinator [%v] (included: [%v], excluded: [%v])", + request.MemberIndex, + request.Attempt.Number, + request.Attempt.CoordinatorMemberIndex, + request.Attempt.IncludedMembersIndexes, + request.Attempt.ExcludedMembersIndexes, + ) + } + + excludedMembersIndexes := []group.MemberIndex{} + if request.Attempt != nil { + excludedMembersIndexes = request.Attempt.ExcludedMembersIndexes + } + + privateKeyShare, err := request.LegacyPrivateKeyShare() + if err != nil { + return nil, err + } + + legacyResult, err := legacySigning.Execute( + ctx, + logger, + request.Message, + request.SessionID, + request.MemberIndex, + privateKeyShare, + request.GroupSize, + request.DishonestThreshold, + excludedMembersIndexes, + request.Channel, + request.MembershipValidator, + ) + if err != nil { + return nil, err + } + + signature, err := FromTECDSASignature(legacyResult.Signature) + if err != nil { + return nil, err + } + + return &Result{ + Signature: signature, + Attempt: cloneAttempt(request.Attempt), + }, nil +} + +func (leb *legacyExecutionBackend) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + legacySigning.RegisterUnmarshallers(channel) +} diff --git a/pkg/frost/signing/native_adapter_build_default_test.go b/pkg/frost/signing/native_adapter_build_default_test.go new file mode 100644 index 0000000000..c9c244292f --- /dev/null +++ b/pkg/frost/signing/native_adapter_build_default_test.go @@ -0,0 +1,26 @@ +//go:build !frost_native + +package signing + +import ( + "errors" + "testing" +) + +func TestNativeExecutionBackend_DefaultBuildUnavailable(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + err := SetExecutionBackendByName("native") + if err == nil { + t.Fatal("expected native backend unavailable error") + } + + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected native backend error\nexpected: [%v]\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } +} diff --git a/pkg/frost/signing/native_adapter_build_frost_native_test.go b/pkg/frost/signing/native_adapter_build_frost_native_test.go new file mode 100644 index 0000000000..e8864a619c --- /dev/null +++ b/pkg/frost/signing/native_adapter_build_frost_native_test.go @@ -0,0 +1,474 @@ +//go:build frost_native + +package signing + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +type mockNativeExecutionBridge struct { + available bool + + executeCalls int + lastRequest *Request + result *Result + err error + + registerUnmarshallersCalls int + lastChannel net.BroadcastChannel +} + +func (mneb *mockNativeExecutionBridge) IsAvailable() bool { + return mneb.available +} + +func (mneb *mockNativeExecutionBridge) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + mneb.executeCalls++ + mneb.lastRequest = request + return mneb.result, mneb.err +} + +func (mneb *mockNativeExecutionBridge) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + mneb.registerUnmarshallersCalls++ + mneb.lastChannel = channel +} + +func staticNativeBridgeProvider( + bridge NativeExecutionBridge, +) func() NativeExecutionBridge { + return func() NativeExecutionBridge { + return bridge + } +} + +func TestNativeExecutionBackend_FrostNativeBuildSelectable(t *testing.T) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + RegisterNativeExecutionAdapterForBuild() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + err := SetExecutionBackendByName("native") + if err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if CurrentExecutionBackendName() != NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + NativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + adapter := newBuildTaggedNativeExecutionAdapter() + + _, err = adapter.Execute(context.Background(), nil, nil) + if err == nil { + t.Fatal("expected request validation error") + } + + if !strings.Contains(err.Error(), "request is nil") { + t.Fatalf( + "unexpected native execution error\nexpected substring: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } + + err = SetExecutionBackendByName("ffi") + if err != nil { + t.Fatalf( + "unexpected strict ffi backend config error\nexpected: [nil]\nactual: [%v]", + err, + ) + } + + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + + err = SetExecutionBackendByName("ffi") + if err == nil { + t.Fatal("expected strict ffi backend unavailable error") + } + + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected ffi backend error\nexpected: [%v]\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected ffi native-availability error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + registeredBridge := &mockNativeExecutionBridge{ + available: true, + result: &Result{}, + } + + err = RegisterNativeExecutionBridge(registeredBridge) + if err != nil { + t.Fatalf("failed registering native execution bridge: [%v]", err) + } + + err = SetExecutionBackendByName("ffi") + if err != nil { + t.Fatalf("unexpected strict ffi backend config error: [%v]", err) + } + + if CurrentExecutionBackendName() != NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name for strict ffi config\nexpected: [%s]\nactual: [%s]", + NativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_UsesNativeBridgeWhenAvailable( + t *testing.T, +) { + expectedResult := &Result{} + bridge := &mockNativeExecutionBridge{ + available: true, + result: expectedResult, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + result, err := adapter.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if bridge.executeCalls != 1 { + t.Fatalf("unexpected bridge execute calls count: [%d]", bridge.executeCalls) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_FallsBackWhenBridgeUnavailable( + t *testing.T, +) { + expectedResult := &Result{} + bridge := &mockNativeExecutionBridge{ + available: false, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: expectedResult, + } + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + result, err := adapter.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if bridge.executeCalls != 0 { + t.Fatalf("unexpected bridge execute calls count: [%d]", bridge.executeCalls) + } + + if fallback.executeCalls != 1 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_FallsBackOnUnavailableBridgeError( + t *testing.T, +) { + expectedResult := &Result{} + bridge := &mockNativeExecutionBridge{ + available: true, + err: ErrNativeCryptographyUnavailable, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: expectedResult, + } + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + result, err := adapter.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if bridge.executeCalls != 1 { + t.Fatalf("unexpected bridge execute calls count: [%d]", bridge.executeCalls) + } + + if fallback.executeCalls != 1 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_ReturnsBridgeError( + t *testing.T, +) { + bridgeError := errors.New("bridge failure") + bridge := &mockNativeExecutionBridge{ + available: true, + err: bridgeError, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + _, err := adapter.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, bridgeError) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + bridgeError, + err, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_StrictModeNoFallbackWhenUnavailable( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + bridge := &mockNativeExecutionBridge{ + available: false, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + _, err := adapter.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_StrictModeNoFallbackOnUnavailableError( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + bridge := &mockNativeExecutionBridge{ + available: true, + err: ErrNativeCryptographyUnavailable, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + _, err := adapter.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_RegisterUnmarshallers_UsesNativeWhenAvailable( + t *testing.T, +) { + bridge := &mockNativeExecutionBridge{ + available: true, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + adapter.RegisterUnmarshallers(nil) + + if bridge.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected bridge register unmarshallers calls count: [%d]", + bridge.registerUnmarshallersCalls, + ) + } + + if fallback.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} + +func TestBuildTaggedNativeExecutionAdapter_RegisterUnmarshallers_FallsBackWhenUnavailable( + t *testing.T, +) { + bridge := &mockNativeExecutionBridge{ + available: false, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + adapter.RegisterUnmarshallers(nil) + + if bridge.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected bridge register unmarshallers calls count: [%d]", + bridge.registerUnmarshallersCalls, + ) + } + + if fallback.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} + +func TestBuildTaggedNativeExecutionAdapter_RegisterUnmarshallers_StrictModeNoFallback( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + bridge := &mockNativeExecutionBridge{ + available: false, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + adapter.RegisterUnmarshallers(nil) + + if fallback.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} diff --git a/pkg/frost/signing/native_adapter_registration.go b/pkg/frost/signing/native_adapter_registration.go new file mode 100644 index 0000000000..c4774da5a9 --- /dev/null +++ b/pkg/frost/signing/native_adapter_registration.go @@ -0,0 +1,5 @@ +package signing + +func init() { + RegisterNativeExecutionAdapterForBuild() +} diff --git a/pkg/frost/signing/native_adapter_registration_default.go b/pkg/frost/signing/native_adapter_registration_default.go new file mode 100644 index 0000000000..065342b1cc --- /dev/null +++ b/pkg/frost/signing/native_adapter_registration_default.go @@ -0,0 +1,5 @@ +//go:build !frost_native + +package signing + +func registerNativeExecutionAdapterForBuild() {} diff --git a/pkg/frost/signing/native_adapter_registration_frost_native.go b/pkg/frost/signing/native_adapter_registration_frost_native.go new file mode 100644 index 0000000000..313971394e --- /dev/null +++ b/pkg/frost/signing/native_adapter_registration_frost_native.go @@ -0,0 +1,142 @@ +//go:build frost_native + +package signing + +import ( + "context" + "errors" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +// buildTaggedNativeExecutionAdapter is a transitional adapter wired when the +// frost_native build tag is enabled. +// +// The adapter uses a native execution bridge when available. +// +// Backend mode behavior: +// - `native`: fallback to legacy bridge when native cryptography is unavailable +// - `ffi`: no fallback; native cryptographic execution is required +type buildTaggedNativeExecutionAdapter struct { + nativeBridgeProvider func() NativeExecutionBridge + fallback ExecutionBackend +} + +func registerNativeExecutionAdapterForBuild() { + // Registration errors are surfaced via `LastNativeRegistrationError()` + // rather than panicking, so a transient registration failure at init time + // does not crash the binary. `currentNativeExecutionBackend()` already + // reports `ErrNativeCryptographyUnavailable` when no native adapter is + // registered, which keeps the legacy execution backend as the safe-by- + // default fallback. + err := RegisterNativeExecutionBridge(newBuildTaggedNativeExecutionBridge()) + if err != nil { + registrationLogger.Warnf( + "failed to register build-tagged native bridge: [%v]; "+ + "native execution will report unavailable and the legacy "+ + "execution backend remains the safe-by-default path", + err, + ) + setLastRegistrationError(fmt.Errorf( + "failed to register build-tagged native bridge: [%w]", + err, + )) + return + } + + err = RegisterNativeExecutionAdapter(newBuildTaggedNativeExecutionAdapter()) + if err != nil { + registrationLogger.Warnf( + "failed to register build-tagged native adapter: [%v]; "+ + "native execution will report unavailable and the legacy "+ + "execution backend remains the safe-by-default path", + err, + ) + setLastRegistrationError(fmt.Errorf( + "failed to register build-tagged native adapter: [%w]", + err, + )) + return + } +} + +func newBuildTaggedNativeExecutionAdapter() *buildTaggedNativeExecutionAdapter { + return &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: newNativeExecutionBridge, + fallback: newLegacyExecutionBackend(), + } +} + +func (btnea *buildTaggedNativeExecutionAdapter) NativeExecutionAvailable() bool { + nativeBridge := btnea.currentNativeBridge() + return nativeBridge != nil && nativeBridge.IsAvailable() +} + +func (btnea *buildTaggedNativeExecutionAdapter) currentNativeBridge() NativeExecutionBridge { + if btnea.nativeBridgeProvider == nil { + return nil + } + + return btnea.nativeBridgeProvider() +} + +func (btnea *buildTaggedNativeExecutionAdapter) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + nativeBridge := btnea.currentNativeBridge() + if nativeBridge != nil && nativeBridge.IsAvailable() { + result, err := nativeBridge.Execute(ctx, logger, request) + if err == nil { + return result, nil + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + return nil, fmt.Errorf("native bridge execution failed: [%w]", err) + } + + if !nativeExecutionFallbackAllowed() { + return nil, err + } + + if logger != nil { + logger.Warnf( + "native FROST cryptography unavailable; falling back to legacy bridge backend: [%v]", + err, + ) + } + } + + if !nativeExecutionFallbackAllowed() { + return nil, ErrNativeCryptographyUnavailable + } + + if btnea.fallback == nil { + return nil, fmt.Errorf("fallback execution backend is nil") + } + + return btnea.fallback.Execute(ctx, logger, request) +} + +func (btnea *buildTaggedNativeExecutionAdapter) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + nativeBridge := btnea.currentNativeBridge() + if nativeBridge != nil && nativeBridge.IsAvailable() { + nativeBridge.RegisterUnmarshallers(channel) + return + } + + if !nativeExecutionFallbackAllowed() { + return + } + + if btnea.fallback == nil { + return + } + + btnea.fallback.RegisterUnmarshallers(channel) +} diff --git a/pkg/frost/signing/native_backend.go b/pkg/frost/signing/native_backend.go new file mode 100644 index 0000000000..68ad402db5 --- /dev/null +++ b/pkg/frost/signing/native_backend.go @@ -0,0 +1,78 @@ +package signing + +import ( + "context" + "errors" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +const nativeExecutionBackendName = "native-frost-ffi" + +// NativeExecutionAdapter is a transitional hook for wiring a future native +// FROST signing implementation (for example, cgo/FFI-backed). +type NativeExecutionAdapter interface { + Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, + ) (*Result, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +type nativeExecutionBackend struct { + adapter NativeExecutionAdapter +} + +func newNativeExecutionBackend( + adapter NativeExecutionAdapter, +) (*nativeExecutionBackend, error) { + if adapter == nil { + return nil, fmt.Errorf("native execution adapter is nil") + } + + return &nativeExecutionBackend{ + adapter: adapter, + }, nil +} + +func (neb *nativeExecutionBackend) Name() string { + return nativeExecutionBackendName +} + +func (neb *nativeExecutionBackend) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + result, err := neb.adapter.Execute(ctx, logger, request) + if err != nil && + InteractiveSigningOnlyEnabled() && + errors.Is(err, ErrNativeCryptographyUnavailable) && + !errors.Is(err, ErrTerminalSigningFailure) { + // Interactive-only mode (coarse-path retirement): the native interactive path + // could not produce a signature and every coarse/legacy fallback is suppressed, + // so an outer refusal surfaces as a bare ErrNativeCryptographyUnavailable. + // Promote it to TERMINAL so the tBTC signingRetryLoop aborts immediately rather + // than retrying a deterministic configuration failure until timeout. + recordCoarseFallbackRefused() + return nil, fmt.Errorf( + "%w: interactive-only signing mode (%s) and the native interactive path is "+ + "unavailable; refusing the coarse fallback: %v", + ErrTerminalSigningFailure, InteractiveSigningOnlyEnvVar, err, + ) + } + return result, err +} + +func (neb *nativeExecutionBackend) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + neb.adapter.RegisterUnmarshallers(channel) +} diff --git a/pkg/frost/signing/native_bridge.go b/pkg/frost/signing/native_bridge.go new file mode 100644 index 0000000000..195369aed9 --- /dev/null +++ b/pkg/frost/signing/native_bridge.go @@ -0,0 +1,100 @@ +package signing + +import ( + "context" + "errors" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +var ( + // ErrNativeCryptographyUnavailable indicates that native FROST + // cryptographic execution is not linked in the current build. + // + // The frost_native adapter handles this condition by falling back to the + // legacy bridge backend. + ErrNativeCryptographyUnavailable = errors.New( + "native FROST cryptographic execution is unavailable", + ) + // ErrNativeBridgeOperationFailed indicates that native cryptographic + // execution is available but a bridge operation returned a non-success + // status. This error should not trigger availability fallback. + ErrNativeBridgeOperationFailed = errors.New( + "native FROST bridge operation failed", + ) +) + +// NativeExecutionBridge defines a native cryptographic execution entrypoint +// used by the frost_native adapter. +// +// The current implementation returns ErrNativeCryptographyUnavailable. Future +// FFI-backed integrations should provide an available bridge implementation. +type NativeExecutionBridge interface { + IsAvailable() bool + Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, + ) (*Result, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +// RegisterNativeExecutionBridge registers a native execution bridge for +// frost_native adapter routing. +func RegisterNativeExecutionBridge(bridge NativeExecutionBridge) error { + if bridge == nil { + return errors.New("native execution bridge is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + registeredNativeExecBridge = bridge + + return nil +} + +// UnregisterNativeExecutionBridge clears the registered native execution +// bridge. +func UnregisterNativeExecutionBridge() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + registeredNativeExecBridge = nil +} + +func currentNativeExecutionBridge() NativeExecutionBridge { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return registeredNativeExecBridge +} + +func newNativeExecutionBridge() NativeExecutionBridge { + bridge := currentNativeExecutionBridge() + if bridge != nil { + return bridge + } + + return &unlinkedNativeExecutionBridge{} +} + +type unlinkedNativeExecutionBridge struct{} + +func (uneb *unlinkedNativeExecutionBridge) IsAvailable() bool { + return false +} + +func (uneb *unlinkedNativeExecutionBridge) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + return nil, ErrNativeCryptographyUnavailable +} + +func (uneb *unlinkedNativeExecutionBridge) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} diff --git a/pkg/frost/signing/native_bridge_frost_native.go b/pkg/frost/signing/native_bridge_frost_native.go new file mode 100644 index 0000000000..1cb5e9d186 --- /dev/null +++ b/pkg/frost/signing/native_bridge_frost_native.go @@ -0,0 +1,104 @@ +//go:build frost_native + +package signing + +import ( + "context" + "errors" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +// buildTaggedNativeExecutionBridge is a transitional native bridge registered +// for frost_native builds. +// +// Until a real FFI-backed bridge is linked, this bridge delegates to the +// legacy signing backend while still surfacing native-bridge availability. +type buildTaggedNativeExecutionBridge struct { + ffiExecutorProvider func() NativeExecutionFFIExecutor + delegate ExecutionBackend +} + +func newBuildTaggedNativeExecutionBridge() NativeExecutionBridge { + return &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: currentNativeExecutionFFIExecutor, + delegate: newLegacyExecutionBackend(), + } +} + +func (btneb *buildTaggedNativeExecutionBridge) IsAvailable() bool { + if btneb.currentFFIExecutor() != nil { + return true + } + + return nativeExecutionFallbackAllowed() && btneb.delegate != nil +} + +func (btneb *buildTaggedNativeExecutionBridge) currentFFIExecutor() NativeExecutionFFIExecutor { + if btneb.ffiExecutorProvider == nil { + return nil + } + + return btneb.ffiExecutorProvider() +} + +func (btneb *buildTaggedNativeExecutionBridge) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + ffiExecutor := btneb.currentFFIExecutor() + if ffiExecutor != nil { + result, err := ffiExecutor.Execute(ctx, logger, request) + if err == nil { + return result, nil + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + return nil, fmt.Errorf("native FFI executor execution failed: [%w]", err) + } + + if !nativeExecutionFallbackAllowed() { + return nil, err + } + + if logger != nil { + logger.Warnf( + "native FFI executor unavailable; falling back to legacy bridge backend: [%v]", + err, + ) + } + } + + if !nativeExecutionFallbackAllowed() { + return nil, ErrNativeCryptographyUnavailable + } + + if btneb.delegate == nil { + return nil, ErrNativeCryptographyUnavailable + } + + return btneb.delegate.Execute(ctx, logger, request) +} + +func (btneb *buildTaggedNativeExecutionBridge) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + ffiExecutor := btneb.currentFFIExecutor() + if ffiExecutor != nil { + ffiExecutor.RegisterUnmarshallers(channel) + return + } + + if !nativeExecutionFallbackAllowed() { + return + } + + if btneb.delegate == nil { + return + } + + btneb.delegate.RegisterUnmarshallers(channel) +} diff --git a/pkg/frost/signing/native_bridge_frost_native_test.go b/pkg/frost/signing/native_bridge_frost_native_test.go new file mode 100644 index 0000000000..0608b743ac --- /dev/null +++ b/pkg/frost/signing/native_bridge_frost_native_test.go @@ -0,0 +1,365 @@ +//go:build frost_native + +package signing + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +type mockNativeExecutionFFIExecutor struct { + executeCalls int + lastRequest *Request + result *Result + err error + + registerUnmarshallersCalls int + lastChannel net.BroadcastChannel +} + +func (mnefe *mockNativeExecutionFFIExecutor) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + mnefe.executeCalls++ + mnefe.lastRequest = request + return mnefe.result, mnefe.err +} + +func (mnefe *mockNativeExecutionFFIExecutor) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + mnefe.registerUnmarshallersCalls++ + mnefe.lastChannel = channel +} + +func staticNativeFFIExecutorProvider( + executor NativeExecutionFFIExecutor, +) func() NativeExecutionFFIExecutor { + return func() NativeExecutionFFIExecutor { + return executor + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_UsesFFIExecutor( + t *testing.T, +) { + expectedResult := &Result{} + ffiExecutor := &mockNativeExecutionFFIExecutor{ + result: expectedResult, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + result, err := bridge.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if ffiExecutor.executeCalls != 1 { + t.Fatalf( + "unexpected ffi executor execute calls count: [%d]", + ffiExecutor.executeCalls, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_StrictNoFallbackWithoutFFIExecutor( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(nil), + delegate: fallback, + } + + _, err := bridge.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_FallsBackWithoutFFIExecutor( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + + expectedResult := &Result{} + fallback := &mockExecutionBackend{ + name: "fallback", + result: expectedResult, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(nil), + delegate: fallback, + } + + result, err := bridge.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if fallback.executeCalls != 1 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_StrictNoFallbackOnFFIUnavailableError( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + ffiExecutor := &mockNativeExecutionFFIExecutor{ + err: ErrNativeCryptographyUnavailable, + } + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + _, err := bridge.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if ffiExecutor.executeCalls != 1 { + t.Fatalf( + "unexpected ffi executor execute calls count: [%d]", + ffiExecutor.executeCalls, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_FallsBackOnFFIUnavailableError( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + expectedResult := &Result{} + ffiExecutor := &mockNativeExecutionFFIExecutor{ + err: ErrNativeCryptographyUnavailable, + } + fallback := &mockExecutionBackend{ + name: "fallback", + result: expectedResult, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + result, err := bridge.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if ffiExecutor.executeCalls != 1 { + t.Fatalf( + "unexpected ffi executor execute calls count: [%d]", + ffiExecutor.executeCalls, + ) + } + + if fallback.executeCalls != 1 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_NoFallbackOnFFIExecutionError( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + ffiExecutionError := errors.New("ffi executor crashed") + ffiExecutor := &mockNativeExecutionFFIExecutor{ + err: ffiExecutionError, + } + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + _, err := bridge.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ffiExecutionError) { + t.Fatalf( + "unexpected execute error\nexpected to wrap: [%v]\nactual: [%v]", + ffiExecutionError, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected availability error wrapping for non-availability failure: [%v]", + err, + ) + } + + if !strings.Contains(err.Error(), "native FFI executor execution failed") { + t.Fatalf("unexpected error message: [%v]", err) + } + + if ffiExecutor.executeCalls != 1 { + t.Fatalf( + "unexpected ffi executor execute calls count: [%d]", + ffiExecutor.executeCalls, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_RegisterUnmarshallers_UsesFFIExecutor( + t *testing.T, +) { + ffiExecutor := &mockNativeExecutionFFIExecutor{} + fallback := &mockExecutionBackend{name: "fallback"} + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + bridge.RegisterUnmarshallers(nil) + + if ffiExecutor.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected ffi executor register unmarshallers calls count: [%d]", + ffiExecutor.registerUnmarshallersCalls, + ) + } + + if fallback.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} + +func TestBuildTaggedNativeExecutionBridge_RegisterUnmarshallers_StrictNoFallback( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + fallback := &mockExecutionBackend{name: "fallback"} + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(nil), + delegate: fallback, + } + + bridge.RegisterUnmarshallers(nil) + + if fallback.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} diff --git a/pkg/frost/signing/native_ffi_executor.go b/pkg/frost/signing/native_ffi_executor.go new file mode 100644 index 0000000000..fe45850d9f --- /dev/null +++ b/pkg/frost/signing/native_ffi_executor.go @@ -0,0 +1,51 @@ +package signing + +import ( + "context" + "errors" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +// NativeExecutionFFIExecutor is a bridge to the native/FFI signing engine. +// This executor is intended to run FROST-native cryptographic execution. +type NativeExecutionFFIExecutor interface { + Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, + ) (*Result, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +// RegisterNativeExecutionFFIExecutor registers a native FFI executor used by +// build-tagged bridges. +func RegisterNativeExecutionFFIExecutor(executor NativeExecutionFFIExecutor) error { + if executor == nil { + return errors.New("native execution FFI executor is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionFFIExecutor = executor + + return nil +} + +// UnregisterNativeExecutionFFIExecutor clears the native FFI executor +// registration. +func UnregisterNativeExecutionFFIExecutor() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionFFIExecutor = nil +} + +func currentNativeExecutionFFIExecutor() NativeExecutionFFIExecutor { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeExecutionFFIExecutor +} diff --git a/pkg/frost/signing/native_ffi_executor_adapter.go b/pkg/frost/signing/native_ffi_executor_adapter.go new file mode 100644 index 0000000000..a839992f17 --- /dev/null +++ b/pkg/frost/signing/native_ffi_executor_adapter.go @@ -0,0 +1,206 @@ +package signing + +import ( + "context" + "fmt" + "math/big" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// NativeExecutionFFISigningRequest is the canonical request passed to a native +// FFI signing primitive. +type NativeExecutionFFISigningRequest struct { + Message *big.Int + SessionID string + RoastSessionID string + MemberIndex group.MemberIndex + GroupSize int + DishonestThreshold int + Channel net.BroadcastChannel + MembershipValidator *group.MembershipValidator + SignerMaterial *NativeSignerMaterial + TaprootMerkleRoot *[32]byte + Attempt *Attempt +} + +// NativeExecutionFFISigningPrimitive is a minimal cryptographic primitive +// interface used by the reusable native FFI executor adapter. +type NativeExecutionFFISigningPrimitive interface { + Sign( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, + ) (*frost.Signature, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +type nativeExecutionFFIExecutorAdapter struct { + primitive NativeExecutionFFISigningPrimitive +} + +// NewNativeExecutionFFIExecutorAdapter wraps a native FFI signing primitive as +// a NativeExecutionFFIExecutor. +func NewNativeExecutionFFIExecutorAdapter( + primitive NativeExecutionFFISigningPrimitive, +) (NativeExecutionFFIExecutor, error) { + if primitive == nil { + return nil, fmt.Errorf("native execution FFI signing primitive is nil") + } + + return &nativeExecutionFFIExecutorAdapter{ + primitive: primitive, + }, nil +} + +// RegisterNativeExecutionFFISigningPrimitive registers a native FFI signing +// primitive by adapting it to NativeExecutionFFIExecutor. +func RegisterNativeExecutionFFISigningPrimitive( + primitive NativeExecutionFFISigningPrimitive, +) error { + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + return err + } + + return RegisterNativeExecutionFFIExecutor(executor) +} + +func (nefea *nativeExecutionFFIExecutorAdapter) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (result *Result, err error) { + // Recover any panic originating along the cgo/FFI signing path (e.g. a + // malformed or oversized response from the native signer) and surface it as + // a failed attempt instead of crashing the whole signing process. The outer + // tBTC signingRetryLoop then handles this attempt cleanly. + defer func() { + if r := recover(); r != nil { + result = nil + err = fmt.Errorf( + "native FFI signing panicked at the cgo boundary: %v", r, + ) + } + }() + + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Message == nil { + return nil, fmt.Errorf("request message is nil") + } + + signerMaterial, err := request.NativeSignerMaterial() + if err != nil { + return nil, fmt.Errorf("%w: [%v]", ErrNativeCryptographyUnavailable, err) + } + + ffiRequest := &NativeExecutionFFISigningRequest{ + Message: request.Message, + SessionID: request.SessionID, + RoastSessionID: request.RoastSessionID, + MemberIndex: request.MemberIndex, + GroupSize: request.GroupSize, + DishonestThreshold: request.DishonestThreshold, + Channel: request.Channel, + MembershipValidator: request.MembershipValidator, + SignerMaterial: signerMaterial, + TaprootMerkleRoot: cloneTaprootMerkleRoot(request.TaprootMerkleRoot), + Attempt: cloneAttempt(request.Attempt), + } + + // RFC-21 Phase 6.3: ROAST orchestration entry. The helper + // returns (cleanup, error): + // - cleanup non-nil, error nil -> orchestration active; + // defer cleanup so success and failure return paths converge. + // - cleanup nil, error nil -> static-configuration fallback + // (env var unset, no coordinator registered, or material + // format not extractable). Proceed without orchestration; the + // receive loops use NoOp recorder semantics (Phase 5 behaviour). + // - cleanup nil, error non-nil -> RUNTIME orchestration failure. + // HARD FAIL to prevent group fracture across honest signers. + // In the default build (no frost_native tag) the helper is a + // permanent no-op returning (nil, nil). + // RFC-21 Phase 6.3 + 7.3: ROAST orchestration + gated interactive signing. + // attemptRoastRetryOrchestrationFromRequest sets up per-session + // orchestration and, when the operator audit gate + // (KEEP_CORE_FROST_INTERACTIVE_SIGNING_ENABLED) is on and an engine is + // registered, drives ONE interactive attempt with the handle minted for THIS + // Execute (the outer tBTC signingRetryLoop owns retries; this is one + // attempt). It returns (signature, cleanup, error): + // - signature non-nil -> interactive signing produced it; return it. + // - signature nil, cleanup non-nil -> orchestration active but interactive + // not enabled; defer cleanup, fall through to the coarse primitive. + // - signature nil, cleanup nil -> static fallback; coarse primitive. + // - error non-nil -> RUNTIME/committed failure; HARD FAIL (cleanup, when + // non-nil, is deferred first so a failed interactive attempt still + // stashes its transition bundle). + // In the default build (no frost_native) the helper is a permanent no-op + // returning (nil, nil, nil). + interactiveSignature, orchCleanup, orchErr := + attemptRoastRetryOrchestrationFromRequest(ctx, ffiRequest, logger) + if orchCleanup != nil { + defer orchCleanup() + } + if orchErr != nil { + return nil, orchErr + } + if interactiveSignature != nil { + return &Result{ + Signature: interactiveSignature, + Attempt: cloneAttempt(request.Attempt), + }, nil + } + + if InteractiveSigningOnlyEnabled() { + // Interactive-only mode (coarse-path retirement): the orchestration produced + // no interactive signature - the audit gate is off, no engine is registered, + // OR any static fallback fired (readiness off, no coordinator, unsupported + // material). Refuse the inner coarse primitive here; the OUTER bridge/adapter + // legacy fallback is closed separately via nativeExecutionFallbackAllowed(). + // Mark the refusal TERMINAL so the tBTC signingRetryLoop aborts immediately + // instead of retrying a deterministic configuration failure until timeout. + recordCoarseFallbackRefused() + return nil, fmt.Errorf( + "%w: interactive-only signing mode (%s) is set but interactive signing did "+ + "not run (%s off, no engine, or static fallback); refusing the coarse fallback", + ErrTerminalSigningFailure, InteractiveSigningOnlyEnvVar, InteractiveSigningOptInEnvVar, + ) + } + + signature, err := nefea.primitive.Sign(ctx, logger, ffiRequest) + if err != nil { + return nil, err + } + + if signature == nil { + return nil, fmt.Errorf("native FFI signing primitive returned nil signature") + } + + return &Result{ + Signature: signature, + Attempt: cloneAttempt(request.Attempt), + }, nil +} + +func (nefea *nativeExecutionFFIExecutorAdapter) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + nefea.primitive.RegisterUnmarshallers(channel) +} + +func cloneTaprootMerkleRoot(taprootMerkleRoot *[32]byte) *[32]byte { + if taprootMerkleRoot == nil { + return nil + } + + result := new([32]byte) + copy(result[:], taprootMerkleRoot[:]) + + return result +} diff --git a/pkg/frost/signing/native_ffi_executor_adapter_test.go b/pkg/frost/signing/native_ffi_executor_adapter_test.go new file mode 100644 index 0000000000..62d6a73d35 --- /dev/null +++ b/pkg/frost/signing/native_ffi_executor_adapter_test.go @@ -0,0 +1,429 @@ +package signing + +import ( + "context" + "errors" + "math/big" + "strings" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type mockNativeExecutionFFISigningPrimitive struct { + signCalls int + lastRequest *NativeExecutionFFISigningRequest + signature *frost.Signature + signErr error + registerCalls int + lastChannel net.BroadcastChannel +} + +func (mnefsp *mockNativeExecutionFFISigningPrimitive) Sign( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + mnefsp.signCalls++ + mnefsp.lastRequest = request + return mnefsp.signature, mnefsp.signErr +} + +func (mnefsp *mockNativeExecutionFFISigningPrimitive) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + mnefsp.registerCalls++ + mnefsp.lastChannel = channel +} + +// panickingFFISigningPrimitive panics in Sign, standing in for a panic raised +// along the cgo/FFI path (e.g. a C.GoBytes length-out-of-range on a malformed +// native-signer response). +type panickingFFISigningPrimitive struct{} + +func (panickingFFISigningPrimitive) Sign( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + panic("simulated cgo boundary panic") +} + +func (panickingFFISigningPrimitive) RegisterUnmarshallers(channel net.BroadcastChannel) {} + +func TestNativeExecutionFFIExecutorAdapter_Execute_RecoversCgoBoundaryPanic( + t *testing.T, +) { + executor, err := NewNativeExecutionFFIExecutorAdapter(panickingFFISigningPrimitive{}) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + result, err := executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(1), + SignerMaterial: []byte{0x01}, + }) + if err == nil { + t.Fatal("expected an error from the recovered panic, got nil") + } + if result != nil { + t.Fatalf("expected a nil result on a recovered panic, got [%v]", result) + } + if !strings.Contains(err.Error(), "panicked at the cgo boundary") { + t.Fatalf( + "expected a cgo-boundary panic error, got: [%v]", + err, + ) + } +} + +func TestNewNativeExecutionFFIExecutorAdapter_NilPrimitive(t *testing.T) { + _, err := NewNativeExecutionFFIExecutorAdapter(nil) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native execution FFI signing primitive is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native execution FFI signing primitive is nil", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_ValidatesRequest(t *testing.T) { + executor, err := NewNativeExecutionFFIExecutorAdapter( + &mockNativeExecutionFFISigningPrimitive{}, + ) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, nil) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "request is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_ValidatesMessage(t *testing.T) { + executor, err := NewNativeExecutionFFIExecutorAdapter( + &mockNativeExecutionFFISigningPrimitive{}, + ) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, &Request{ + SignerMaterial: []byte{0x01}, + }) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "request message is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "request message is nil", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_ValidatesSignerMaterial( + t *testing.T, +) { + executor, err := NewNativeExecutionFFIExecutorAdapter( + &mockNativeExecutionFFISigningPrimitive{}, + ) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(1), + SignerMaterial: "invalid", + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !strings.Contains(err.Error(), "native signer material has wrong type") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native signer material has wrong type", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_DelegatesToPrimitive( + t *testing.T, +) { + expectedSignature := &frost.Signature{ + R: [frost.SignatureComponentSize]byte{0x01}, + S: [frost.SignatureComponentSize]byte{0x02}, + } + + primitive := &mockNativeExecutionFFISigningPrimitive{ + signature: expectedSignature, + } + + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + attempt := &Attempt{ + Number: 3, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3}, + ExcludedMembersIndexes: []group.MemberIndex{4}, + } + + result, err := executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 2, + GroupSize: 5, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0xaa}, + }, + Attempt: attempt, + }) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result == nil || result.Signature != expectedSignature { + t.Fatalf( + "unexpected result signature\nexpected: [%+v]\nactual: [%+v]", + expectedSignature, + result, + ) + } + + if primitive.signCalls != 1 { + t.Fatalf("unexpected primitive sign calls count: [%d]", primitive.signCalls) + } + + if primitive.lastRequest == nil { + t.Fatal("expected primitive request") + } + + if primitive.lastRequest.SignerMaterial == nil { + t.Fatal("expected signer material in primitive request") + } + + if primitive.lastRequest.Attempt == attempt { + t.Fatal("expected attempt clone in primitive request") + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_PropagatesPrimitiveError( + t *testing.T, +) { + expectedErr := errors.New("native signer failure") + primitive := &mockNativeExecutionFFISigningPrimitive{ + signErr: expectedErr, + } + + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(1), + SignerMaterial: []byte{0x01}, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, expectedErr) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + expectedErr, + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_RejectsNilSignature( + t *testing.T, +) { + primitive := &mockNativeExecutionFFISigningPrimitive{} + + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(1), + SignerMaterial: []byte{0x01}, + }) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "returned nil signature") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "returned nil signature", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_InteractiveOnlyRefusesCoarse( + t *testing.T, +) { + // Coarse-path retirement: with interactive-only mode ON, the adapter must NOT fall + // through to the coarse primitive on ANY no-interactive-signature path. In the + // default build attemptRoastRetryOrchestrationFromRequest is a no-op (nil,nil,nil) + // - the ultimate static fallback - so this exercises exactly the gap Codex flagged: + // a fall-through that an executor-level check (which runs only after orchestration + // activates) would have bypassed. The adapter is the single coarse-invocation + // point, so the refusal here covers every path. + t.Setenv(InteractiveSigningOnlyEnvVar, "true") + + primitive := &mockNativeExecutionFFISigningPrimitive{ + signature: &frost.Signature{ + R: [frost.SignatureComponentSize]byte{0x01}, + S: [frost.SignatureComponentSize]byte{0x02}, + }, + } + + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(123), + SessionID: "session-interactive-only", + MemberIndex: 2, + GroupSize: 5, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0xaa}, + }, + Attempt: &Attempt{ + Number: 3, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3}, + }, + }) + if err == nil { + t.Fatal("interactive-only mode must fail closed instead of using the coarse primitive") + } + if !errors.Is(err, ErrTerminalSigningFailure) { + t.Fatalf("interactive-only refusal must be TERMINAL so the retry loop aborts: %v", err) + } + if !strings.Contains(err.Error(), InteractiveSigningOnlyEnvVar) { + t.Fatalf("unexpected error (want a refusal naming %s): %v", InteractiveSigningOnlyEnvVar, err) + } + if primitive.signCalls != 0 { + t.Fatalf( + "coarse primitive must NOT be called in interactive-only mode, got %d call(s)", + primitive.signCalls, + ) + } +} + +func TestInteractiveSigningOnlyEnabled_ParsesFlag(t *testing.T) { + t.Setenv(InteractiveSigningOnlyEnvVar, "") + if InteractiveSigningOnlyEnabled() { + t.Fatal("unset must be off") + } + t.Setenv(InteractiveSigningOnlyEnvVar, " TrUe ") + if !InteractiveSigningOnlyEnabled() { + t.Fatal("case-insensitive, trimmed true must be on") + } + t.Setenv(InteractiveSigningOnlyEnvVar, "false") + if InteractiveSigningOnlyEnabled() { + t.Fatal("false must be off") + } +} + +func TestNativeExecutionFFIExecutorAdapter_RegisterUnmarshallers_Delegates( + t *testing.T, +) { + primitive := &mockNativeExecutionFFISigningPrimitive{} + + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + var channel net.BroadcastChannel + executor.RegisterUnmarshallers(channel) + + if primitive.registerCalls != 1 { + t.Fatalf( + "unexpected register unmarshallers calls count: [%d]", + primitive.registerCalls, + ) + } +} + +func TestRegisterNativeExecutionFFISigningPrimitive_Nil(t *testing.T) { + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + err := RegisterNativeExecutionFFISigningPrimitive(nil) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native execution FFI signing primitive is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native execution FFI signing primitive is nil", + err, + ) + } +} + +func TestRegisterNativeExecutionFFISigningPrimitive_RegistersExecutor(t *testing.T) { + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + err := RegisterNativeExecutionFFISigningPrimitive( + &mockNativeExecutionFFISigningPrimitive{ + signature: &frost.Signature{}, + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + executor := currentNativeExecutionFFIExecutor() + if executor == nil { + t.Fatal("expected native FFI executor registration") + } +} diff --git a/pkg/frost/signing/native_ffi_primitive_abi_preflight_frost_native_default.go b/pkg/frost/signing/native_ffi_primitive_abi_preflight_frost_native_default.go new file mode 100644 index 0000000000..b65d2ebcab --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_abi_preflight_frost_native_default.go @@ -0,0 +1,8 @@ +//go:build frost_native && (!frost_tbtc_signer || !cgo) + +package signing + +// tbtcSignerABIIncompatibility is a no-op in builds without the linked cgo engine: there +// is no real libfrost_tbtc to be incompatible with. The cgo-tagged variant +// (native_ffi_primitive_abi_preflight_frost_native_tbtc_signer.go) does the real check. +func tbtcSignerABIIncompatibility() error { return nil } diff --git a/pkg/frost/signing/native_ffi_primitive_abi_preflight_frost_native_tbtc_signer.go b/pkg/frost/signing/native_ffi_primitive_abi_preflight_frost_native_tbtc_signer.go new file mode 100644 index 0000000000..e3a86f75ab --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_abi_preflight_frost_native_tbtc_signer.go @@ -0,0 +1,18 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import "errors" + +// tbtcSignerABIIncompatibility reports a PRESENT-but-incompatible linked libfrost_tbtc +// (a wrong or malformed FFI contract version) so the coarse engine path can fail closed +// BEFORE its legacy fallback can mask it. It returns nil for a MISSING/absent lib +// (ErrNativeCryptographyUnavailable, not ErrTBTCSignerABIIncompatible): that case is the +// intended transitional "no engine -> legacy" fallback, not an incompatibility. The +// underlying preflight is cached (sync.Once), so this is cheap to call per signing. +func tbtcSignerABIIncompatibility() error { + if err := ensureTBTCSignerABICompatible(); errors.Is(err, ErrTBTCSignerABIIncompatible) { + return err + } + return nil +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration.go b/pkg/frost/signing/native_ffi_primitive_registration.go new file mode 100644 index 0000000000..3a0de23572 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration.go @@ -0,0 +1,111 @@ +package signing + +import ( + "fmt" + "sync" + + "github.com/ipfs/go-log/v2" +) + +var ( + registrationLogger = log.Logger("keep-frost-signing-registration") + protocolLogger = log.Logger("keep-frost-signing-protocol") + registrationErrorMu sync.RWMutex + lastRegistrationError error +) + +func setLastRegistrationError(err error) { + registrationErrorMu.Lock() + defer registrationErrorMu.Unlock() + lastRegistrationError = err +} + +// LastNativeRegistrationError returns the most recent error observed while +// registering build-tagged native FROST execution adapters or FFI signing +// primitives. It is nil when the most recent registration attempt succeeded +// or when no registration has been attempted yet. Callers that want to fail +// startup on a registration error should check this after invoking +// `RegisterNativeExecutionAdapterForBuild` rather than relying on the +// previously panicking registration helpers themselves. Note that when +// TBTC_SIGNER_INIT_CONFIG_PATH is set, registration enforces this itself: +// an unmet config-mode demand is process-fatal (see +// enforceNativeInitConfigDemand), so callers only need this check for +// env-fallback-mode policies of their own. +func LastNativeRegistrationError() error { + registrationErrorMu.RLock() + defer registrationErrorMu.RUnlock() + return lastRegistrationError +} + +// NativeExecutionFFISigningPrimitiveProviderForBuild produces a native FFI +// signing primitive for the current build/runtime flavor. +type NativeExecutionFFISigningPrimitiveProviderForBuild func() ( + NativeExecutionFFISigningPrimitive, + error, +) + +// RegisterNativeExecutionFFISigningPrimitiveProviderForBuild registers +// build-scoped primitive provider used by +// RegisterNativeExecutionFFISigningPrimitiveForBuild. +func RegisterNativeExecutionFFISigningPrimitiveProviderForBuild( + provider NativeExecutionFFISigningPrimitiveProviderForBuild, +) error { + if provider == nil { + return fmt.Errorf("native execution FFI signing primitive provider is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionFFISigningPrimitiveProviderForBuild = provider + + return nil +} + +// UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild clears +// build-scoped primitive provider registration. +func UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionFFISigningPrimitiveProviderForBuild = nil +} + +func currentNativeExecutionFFISigningPrimitiveProviderForBuild() NativeExecutionFFISigningPrimitiveProviderForBuild { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeExecutionFFISigningPrimitiveProviderForBuild +} + +// RegisterNativeExecutionFFISigningPrimitiveForBuild attempts to register +// build-flavor native FFI signing primitive bindings. +// +// On default builds, this is a no-op. +// On `frost_native` builds, this can be wired to a concrete primitive. +// +// Registration errors are surfaced via `LastNativeRegistrationError()` rather +// than panicking, so a transient FFI lookup failure at init time does not +// crash the binary. Downstream code in `pkg/frost/signing/backend.go` already +// handles the absence of a registered native adapter through +// `ErrNativeCryptographyUnavailable`, so the legacy execution backend remains +// the safe-by-default path even when this registration fails. +func RegisterNativeExecutionFFISigningPrimitiveForBuild() { + err := registerNativeExecutionFFISigningPrimitiveForBuild() + if err != nil { + registrationLogger.Warnf( + "failed to register build-tagged native FFI signing primitive: [%v]; "+ + "the native execution backend will report unavailable and callers "+ + "that selected the legacy or native-with-fallback backend will "+ + "continue using the legacy bridge", + err, + ) + setLastRegistrationError(fmt.Errorf( + "failed to register build-tagged native FFI signing primitive: [%w]", + err, + )) + return + } + + setLastRegistrationError(nil) +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_default.go b/pkg/frost/signing/native_ffi_primitive_registration_default.go new file mode 100644 index 0000000000..a68007ea45 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_default.go @@ -0,0 +1,7 @@ +//go:build !frost_native + +package signing + +func registerNativeExecutionFFISigningPrimitiveForBuild() error { + return nil +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_default_build_test.go b/pkg/frost/signing/native_ffi_primitive_registration_default_build_test.go new file mode 100644 index 0000000000..6b492f8877 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_default_build_test.go @@ -0,0 +1,20 @@ +//go:build !frost_native + +package signing + +import "testing" + +func TestRegisterNativeExecutionFFISigningPrimitiveForBuild_DefaultBuildNoop( + t *testing.T, +) { + UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + RegisterNativeExecutionFFISigningPrimitiveForBuild() + + if currentNativeExecutionFFIExecutor() != nil { + t.Fatal("expected no FFI executor registration on default build") + } +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_frost_native.go b/pkg/frost/signing/native_ffi_primitive_registration_frost_native.go new file mode 100644 index 0000000000..d6d3b3b3c8 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_frost_native.go @@ -0,0 +1,23 @@ +//go:build frost_native + +package signing + +import "fmt" + +func registerNativeExecutionFFISigningPrimitiveForBuild() error { + provider := currentNativeExecutionFFISigningPrimitiveProviderForBuild() + if provider == nil { + provider = defaultNativeExecutionFFISigningPrimitiveProviderForBuild + } + + primitive, err := provider() + if err != nil { + return err + } + + if primitive == nil { + return fmt.Errorf("native execution FFI signing primitive is nil") + } + + return RegisterNativeExecutionFFISigningPrimitive(primitive) +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_frost_native_test.go b/pkg/frost/signing/native_ffi_primitive_registration_frost_native_test.go new file mode 100644 index 0000000000..76a4ad4c11 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_frost_native_test.go @@ -0,0 +1,114 @@ +//go:build frost_native + +package signing + +import ( + "errors" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/frost" +) + +func TestRegisterNativeExecutionFFISigningPrimitiveForBuild_UsesProvider( + t *testing.T, +) { + UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + err := RegisterNativeExecutionFFISigningPrimitiveProviderForBuild( + func() (NativeExecutionFFISigningPrimitive, error) { + return &mockNativeExecutionFFISigningPrimitive{ + signature: &frost.Signature{}, + }, nil + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + RegisterNativeExecutionFFISigningPrimitiveForBuild() + + if currentNativeExecutionFFIExecutor() == nil { + t.Fatal("expected FFI executor registration from build provider") + } +} + +func TestRegisterNativeExecutionFFISigningPrimitiveForBuild_UsesDefaultProvider( + t *testing.T, +) { + UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() + UnregisterNativeExecutionFFIExecutor() + // Under the cgo build the default provider's registration installs the + // interactive signing provider as a side effect, so reset it here too - + // symmetric with the FFI executor/provider - both before (clean slate) and in + // cleanup. Otherwise a later test asserting no interactive provider is set + // (TestRegisterInteractiveSigningEngineProvider) fails under -shuffle or a + // focused -run. Resetting is a no-op on builds that register no provider. + ResetInteractiveSigningEngineProviderForTest() + t.Cleanup(UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + t.Cleanup(ResetInteractiveSigningEngineProviderForTest) + + RegisterNativeExecutionFFISigningPrimitiveForBuild() + + if currentNativeExecutionFFIExecutor() == nil { + t.Fatal("expected FFI executor registration from default build provider") + } +} + +func TestRegisterNativeExecutionFFISigningPrimitiveForBuild_ProviderErrorIsRecordedNotPanicked( + t *testing.T, +) { + UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + t.Cleanup(func() { setLastRegistrationError(nil) }) + + expectedErr := errors.New("provider error") + + err := RegisterNativeExecutionFFISigningPrimitiveProviderForBuild( + func() (NativeExecutionFFISigningPrimitive, error) { + return nil, expectedErr + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + defer func() { + if recovered := recover(); recovered != nil { + t.Fatalf( + "registration must not panic; recovered: [%v]", + recovered, + ) + } + }() + + // Pre-condition: the registration error slot is clear before invoking the + // helper, so any non-nil error after the call is from this attempt. + setLastRegistrationError(nil) + + RegisterNativeExecutionFFISigningPrimitiveForBuild() + + registered := LastNativeRegistrationError() + if registered == nil { + t.Fatal("expected LastNativeRegistrationError to surface the provider error") + } + if !strings.Contains(registered.Error(), expectedErr.Error()) { + t.Fatalf( + "LastNativeRegistrationError missing expected substring\nexpected: [%s]\nactual: [%v]", + expectedErr.Error(), + registered, + ) + } + + if currentNativeExecutionFFIExecutor() != nil { + t.Fatal( + "FFI executor must not be registered when the provider returned an error", + ) + } +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_test.go b/pkg/frost/signing/native_ffi_primitive_registration_test.go new file mode 100644 index 0000000000..6711b0b105 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_test.go @@ -0,0 +1,26 @@ +package signing + +import ( + "strings" + "testing" +) + +func TestRegisterNativeExecutionFFISigningPrimitiveProviderForBuild_Nil( + t *testing.T, +) { + err := RegisterNativeExecutionFFISigningPrimitiveProviderForBuild(nil) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains( + err.Error(), + "native execution FFI signing primitive provider is nil", + ) { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native execution FFI signing primitive provider is nil", + err, + ) + } +} diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go new file mode 100644 index 0000000000..3a7318957f --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go @@ -0,0 +1,1657 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" + legacySigning "github.com/keep-network/keep-core/pkg/tecdsa/signing" +) + +func defaultNativeExecutionFFISigningPrimitiveProviderForBuild() ( + NativeExecutionFFISigningPrimitive, + error, +) { + if err := installConfiguredTBTCSignerInitConfig(); err != nil { + return nil, err + } + + if err := registerBuildTaggedNativeFROSTSigningEngine(); err != nil { + return nil, err + } + + return &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{}, nil +} + +// installConfiguredTBTCSignerInitConfig installs the tbtc-signer init-time +// configuration when TBTC_SIGNER_INIT_CONFIG_PATH points at a JSON config +// file. The operator setting the path is an explicit demand for config-mode +// operation, so every failure - unreadable file, validation rejection, or a +// loaded signer library that predates frost_tbtc_init_signer_config - fails +// the FROST-native engine registration, and the demand enforcement at the +// end of registration (enforceNativeInitConfigDemand) then terminates the +// process: a node that cannot honor its demanded config must not run +// half-alive on the legacy bridge. The failure is logged at error level +// here with the config-mode context before the fatal exit so the cause is +// on record. With the path unset this is a no-op and the signer reads +// TBTC_SIGNER_* from the process environment (the transitional path), where +// registration failures keep the safe-by-default degrade posture. +func installConfiguredTBTCSignerInitConfig() error { + configPath := strings.TrimSpace(os.Getenv(TBTCSignerInitConfigPathEnv)) + if configPath == "" { + return nil + } + + configJSON, err := os.ReadFile(configPath) + if err != nil { + err = fmt.Errorf( + "read tbtc-signer init config [%s]: %w", + configPath, err, + ) + registrationLogger.Errorf( + "tbtc-signer init config installation failed; FROST-native "+ + "engine registration fails closed and the process will "+ + "terminate at the end of registration (config-mode demand "+ + "unmet): [%v]", + err, + ) + return err + } + + result, err := InstallNativeTBTCSignerConfig(configJSON) + if err != nil { + err = fmt.Errorf( + "install tbtc-signer init config from [%s]: %w", + configPath, err, + ) + registrationLogger.Errorf( + "tbtc-signer init config installation failed; FROST-native "+ + "engine registration fails closed and the process will "+ + "terminate at the end of registration (config-mode demand "+ + "unmet): [%v]", + err, + ) + return err + } + + registrationLogger.Infof( + "installed tbtc-signer init config from [%s]: fingerprint [%s], "+ + "configured keys [%d], idempotent [%v]", + configPath, + result.ConfigFingerprint, + result.ConfiguredKeyCount, + result.Idempotent, + ) + return nil +} + +// buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive is a +// transitional primitive that preserves legacy bridge execution for +// `frost-uniffi-v1` payloads. `frost-tbtc-signer-v1` uses the coarse signing +// flow for bootstrap engine versions and falls back to legacy signing for +// unsupported or failed coarse-path executions. Unsupported +// `frost-uniffi-v2` material is rejected explicitly because it cannot produce +// Taproot-tweaked signatures; accepting it would allow new deposits to a +// wallet that cannot sweep them. +type buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive struct{} + +const buildTaggedTBTCSignerVersionPrefix = "tbtc-signer/" +const buildTaggedTBTCSignerBootstrapVersionPrerelease = "bootstrap" +const buildTaggedTBTCSignerSyntheticContributionDomain = "tbtc-signer-bootstrap-contribution-v1" +const buildTaggedTBTCSignerMessageTypePrefix = "frost_signing/native_tbtc_signer/" +const buildTaggedTBTCSignerConsumedAttemptReplayErrorFragment = "already consumed for sign attempt" + +// buildTaggedTBTCSignerConsumedAttemptReplayErrorCode is the structured Rust +// `ErrorResponse.code` value emitted by tbtc-signer when an `attempt_id` is +// reused after consumption. Preferred over substring matching on the message +// because the code is contract-stable: see `EngineError::code()` in the +// `tbtc-signer` crate. +const buildTaggedTBTCSignerConsumedAttemptReplayErrorCode = "consumed_attempt_replay" + +// buildTaggedTBTCSignerLegacyValidationErrorCode is the structured code +// emitted by tbtc-signer builds that pre-date the dedicated replay variant. +// Those builds route the replay path through `EngineError::Validation`, so +// the code on the wire is `validation_error` and the substring check on the +// message is the only signal callers have. Once the rolling upgrade is past +// the minimum-supported signer version, this code can be retired. +const buildTaggedTBTCSignerLegacyValidationErrorCode = "validation_error" + +var ( + // ErrInvalidSigningAttemptPolicy indicates the provided attempt metadata + // violates coordinator/cohort policy invariants. + ErrInvalidSigningAttemptPolicy = errors.New("invalid signing attempt policy") + // ErrConsumedSigningAttemptReplay indicates signer-side replay protection + // rejected a previously consumed signing attempt payload. + ErrConsumedSigningAttemptReplay = errors.New("consumed signing attempt replay") +) + +type nativeTBTCSignerVersionedEngine interface { + Version() (string, error) +} + +type buildTaggedTBTCSignerRoundContributionMessage struct { + SenderIDValue uint32 `json:"senderID"` + SessionIDValue string `json:"sessionID"` + ContributionIdentifier uint16 `json:"contributionIdentifier"` + ContributionData []byte `json:"contributionData"` + // AttemptContextHash binds this contribution to an RFC-21 attempt + // context when ROAST retry has registered one for the session. + AttemptContextHash []byte `json:"attemptContextHash,omitempty"` +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) SenderID() group.MemberIndex { + return group.MemberIndex(bttsrcm.SenderIDValue) +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) SessionID() string { + return bttsrcm.SessionIDValue +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) Type() string { + return buildTaggedTBTCSignerMessageTypePrefix + "round_contribution" +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) Marshal() ([]byte, error) { + return json.Marshal(bttsrcm) +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) Unmarshal(data []byte) error { + if err := json.Unmarshal(data, bttsrcm); err != nil { + return err + } + + if bttsrcm.SenderID() == 0 { + return fmt.Errorf("sender ID is zero") + } + + if bttsrcm.SessionID() == "" { + return fmt.Errorf("session ID is empty") + } + + if bttsrcm.ContributionIdentifier == 0 { + return fmt.Errorf("contribution identifier is zero") + } + + if len(bttsrcm.ContributionData) == 0 { + return fmt.Errorf("contribution data is empty") + } + + if err := validateAttemptContextHashField( + bttsrcm.AttemptContextHash, + ); err != nil { + return err + } + + return nil +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) SetAttemptContextHash( + hash [AttemptContextHashFieldLength]byte, +) { + bttsrcm.AttemptContextHash = attemptContextHashFieldFromArray(hash) +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return attemptContextHashFieldToArray(bttsrcm.AttemptContextHash) +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) Sign( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Message == nil { + return nil, fmt.Errorf("request message is nil") + } + + if request.SignerMaterial == nil { + return nil, fmt.Errorf( + "%w: signer material is nil", + ErrNativeCryptographyUnavailable, + ) + } + + switch request.SignerMaterial.Format { + case NativeSignerMaterialFormatFrostUniFFIV2: + return nil, fmt.Errorf( + "%w: unsupported UniFFI FROST signer material format [%s]; it cannot sweep Taproot deposits; use [%s]", + ErrUnsupportedSignerMaterialFormat, + NativeSignerMaterialFormatFrostUniFFIV2, + NativeSignerMaterialFormatFrostTBTCSignerV1, + ) + + case NativeSignerMaterialFormatFrostUniFFIV1: + return btlcnnefsp.signWithLegacyTECDSABridge(ctx, logger, request) + + case NativeSignerMaterialFormatFrostTBTCSignerV1: + return btlcnnefsp.signWithTBTCSignerCoarseEngine(ctx, logger, request) + + default: + return nil, fmt.Errorf( + "%w: unsupported signer material format: [%s]", + ErrNativeCryptographyUnavailable, + request.SignerMaterial.Format, + ) + } +} + +// tbtcSignerABIIncompatibilityCheck is the engine-lib ABI preflight the coarse signing +// path consults before any legacy fallback. It points at the build-split +// tbtcSignerABIIncompatibility (the real cgo check, or the non-cgo no-op); a test seam +// so the fail-closed-no-fallback behavior is exercisable in the frost_native build. +var tbtcSignerABIIncompatibilityCheck = tbtcSignerABIIncompatibility + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) signWithTBTCSignerCoarseEngine( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + // Fail closed on a PRESENT-but-incompatible engine lib BEFORE any legacy fallback + // below can mask it (Codex #4105 P2). The FFI preflight surfaces a wrong/malformed + // contract version as ErrTBTCSignerABIIncompatible; every fallbackTBTCSignerLegacySigning + // path below would otherwise swallow that into a legacy tECDSA signature if the + // material carries a private key share. A MISSING/absent lib is NOT this sentinel + // (it is ErrNativeCryptographyUnavailable) and still falls back as intended. + // Indirected through a var so the no-fallback behavior is testable in the + // frost_native build, where tbtcSignerABIIncompatibility is the non-cgo no-op. + if err := tbtcSignerABIIncompatibilityCheck(); err != nil { + return nil, err + } + + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(request.SignerMaterial) + if err != nil { + return nil, err + } + + // Scaffold persistence-vs-execution gate. The resolver in #3959 refuses to + // BUILD scaffold-era signer material without the env opt-in, but material + // persisted from a previous opted-in session can still drive this signing + // path on later runs after the operator has unset the flag. Refuse to + // enter the FFI scaffold path (which feeds placeholder participant + // pubkeys into RunDKG) when the payload is scaffold-era and the operator + // has not actively opted in for this process. The check is per-call (not + // cached) so flipping the env back unset recovers fail-closed behavior + // without a restart, matching the contract documented on + // AcceptScaffoldKeyGroupEnvVar. + if payload.KeyGroupSource == NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey && + !AcceptScaffoldKeyGroupEnabled() { + return nil, fmt.Errorf( + "%w: refusing to drive the tbtc-signer FFI signing path with "+ + "scaffold-era %q signer material; set %s=true to opt in for "+ + "local/CI use only, never in production", + ErrNativeCryptographyUnavailable, + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + AcceptScaffoldKeyGroupEnvVar, + ) + } + + legacyPrivateKeyShare, err := decodeBuildTaggedTBTCSignerLegacyPrivateKeyShare(payload) + if err != nil { + return nil, err + } + + nativeEngine := currentNativeTBTCSignerEngine() + if nativeEngine == nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + "native tbtc-signer engine is unavailable", + payload.KeyGroupSource, + ) + } + + includedMembersSet, includedMembersIndexes, err := includedMembersFromRequest(request) + if err != nil { + if errors.Is(err, ErrInvalidSigningAttemptPolicy) { + return nil, fmt.Errorf( + "%w: invalid tbtc-signer signing attempt policy: %w", + ErrNativeBridgeOperationFailed, + err, + ) + } + + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("cannot determine included members: [%v]", err), + payload.KeyGroupSource, + ) + } + + dkgParticipants, dkgThreshold, err := buildTaggedTBTCSignerRunDKGInputsForPayload( + payload, + request, + includedMembersIndexes, + ) + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("cannot prepare tbtc-signer RunDKG request: [%v]", err), + payload.KeyGroupSource, + ) + } + + dkgResult, err := runNativeTBTCSignerDKG( + nativeEngine, + request.SessionID, + dkgParticipants, + dkgThreshold, + payload.DKGSeedHex, + ) + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("tbtc-signer RunDKG failed: [%v]", err), + payload.KeyGroupSource, + ) + } + + if dkgResult == nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + "tbtc-signer RunDKG returned nil result", + payload.KeyGroupSource, + ) + } + + if dkgResult.KeyGroup == "" { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + "tbtc-signer RunDKG returned empty key group", + payload.KeyGroupSource, + ) + } + + keyGroupForRound, keyGroupSubstituted, err := buildTaggedTBTCSignerRoundKeyGroup( + payload, + dkgResult, + ) + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + err.Error(), + payload.KeyGroupSource, + ) + } + + if keyGroupSubstituted && logger != nil { + logger.Debugf( + "substituting scaffold key group from payload source [%s]: payload [%s] -> RunDKG [%s]", + payload.KeyGroupSource, + payload.KeyGroup, + dkgResult.KeyGroup, + ) + } + + versionedEngine, isVersioned := nativeEngine.(nativeTBTCSignerVersionedEngine) + if !isVersioned { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + "tbtc-signer version API is unavailable; coarse round scaffold skipped", + payload.KeyGroupSource, + ) + } + + engineVersion, err := versionedEngine.Version() + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf( + "cannot query tbtc-signer version; coarse round scaffold skipped: [%v]", + err, + ), + payload.KeyGroupSource, + ) + } + + if !isBuildTaggedTBTCSignerBootstrapVersion(engineVersion) { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf( + "tbtc-signer version [%s] is not bootstrap; coarse round scaffold skipped", + engineVersion, + ), + payload.KeyGroupSource, + ) + } + + coarseSignatureBytes, err := executeBuildTaggedTBTCSignerBootstrapCoarseRoundWithSignature( + ctx, + request, + keyGroupForRound, + nativeEngine, + includedMembersSet, + includedMembersIndexes, + ) + if err != nil { + if isBuildTaggedTBTCSignerConsumedAttemptReplayError(err) { + return nil, fmt.Errorf( + "%w: consumed tbtc-signer attempt replay: %w: %v", + ErrNativeBridgeOperationFailed, + ErrConsumedSigningAttemptReplay, + err, + ) + } + + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("tbtc-signer bootstrap coarse round failed: [%v]", err), + payload.KeyGroupSource, + ) + } + + coarseSignature, err := decodeBuildTaggedTBTCSignerSignature(coarseSignatureBytes) + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("cannot decode tbtc-signer coarse signature: [%v]", err), + payload.KeyGroupSource, + ) + } + + if logger != nil { + logger.Debugf( + "validated tbtc-signer key-group contract via RunDKG and bootstrap coarse round; returning coarse signature", + ) + } + + emitNativeTBTCSignerCoarseSignatureEvent( + NativeTBTCSignerCoarseSignatureEvent{ + SessionID: request.SessionID, + KeyGroupSource: payload.KeyGroupSource, + EngineVersion: engineVersion, + }, + ) + + return coarseSignature, nil +} + +func isBuildTaggedTBTCSignerConsumedAttemptReplayError(err error) bool { + if err == nil { + return false + } + + // Prefer the structured `code` field from the FFI error envelope when it + // is reachable through the error chain. The Rust signer's + // `EngineError::code()` value `"consumed_attempt_replay"` is a + // contract-stable identifier; this check survives any cosmetic rewording + // of the human-readable message on either side. + // + // Older signer builds emit `validation_error` for the replay path with + // the legacy wording in the message. For those, fall through to the + // substring check restricted to the structured message field so a + // `validation_error` carrying an unrelated error chain string cannot be + // mistaken for a replay. Any other recognized code is authoritative. + var structured *buildTaggedTBTCSignerStructuredError + if errors.As(err, &structured) && structured.Code != "" { + switch structured.Code { + case buildTaggedTBTCSignerConsumedAttemptReplayErrorCode: + return true + case buildTaggedTBTCSignerLegacyValidationErrorCode: + return messageMatchesLegacyConsumedAttemptReplay(structured.Message) + default: + return false + } + } + + // No structured code reachable — the error chain pre-dates the FFI + // envelope. The legacy wording is preserved by the current tbtc-signer + // release so this branch continues to work during the rolling upgrade + // window. Match on the whole rendered string for maximum compatibility. + return messageMatchesLegacyConsumedAttemptReplay(err.Error()) +} + +func messageMatchesLegacyConsumedAttemptReplay(message string) bool { + lower := strings.ToLower(message) + return strings.Contains(lower, "attempt_id") && + strings.Contains(lower, buildTaggedTBTCSignerConsumedAttemptReplayErrorFragment) +} + +func buildTaggedTBTCSignerRunDKGInputs( + request *NativeExecutionFFISigningRequest, +) ([]NativeTBTCSignerDKGParticipant, uint16, error) { + _, includedMembersIndexes, err := includedMembersFromRequest(request) + if err != nil { + return nil, 0, err + } + + return buildTaggedTBTCSignerRunDKGInputsForIncludedMembers( + request, + includedMembersIndexes, + ) +} + +func includedMembersFromRequest( + request *NativeExecutionFFISigningRequest, +) (map[group.MemberIndex]struct{}, []group.MemberIndex, error) { + if request == nil { + return nil, nil, fmt.Errorf("request is nil") + } + + if request.GroupSize <= 0 { + return nil, nil, fmt.Errorf("group size must be positive") + } + + attempt := request.Attempt + if attempt != nil { + if attempt.Number == 0 { + return nil, nil, fmt.Errorf( + "%w: attempt number is zero", + ErrInvalidSigningAttemptPolicy, + ) + } + + if attempt.CoordinatorMemberIndex == 0 { + return nil, nil, fmt.Errorf( + "%w: attempt coordinator member index is zero", + ErrInvalidSigningAttemptPolicy, + ) + } + } + + includedMembersSet := make(map[group.MemberIndex]struct{}) + excludedMembersSet := make(map[group.MemberIndex]struct{}) + + if attempt != nil { + for _, memberIndex := range attempt.ExcludedMembersIndexes { + if memberIndex == 0 { + continue + } + + excludedMembersSet[memberIndex] = struct{}{} + } + } + + if attempt != nil && len(attempt.IncludedMembersIndexes) > 0 { + for _, memberIndex := range attempt.IncludedMembersIndexes { + if memberIndex == 0 { + return nil, nil, fmt.Errorf( + "%w: included member index is zero", + ErrInvalidSigningAttemptPolicy, + ) + } + + if _, excluded := excludedMembersSet[memberIndex]; excluded { + return nil, nil, fmt.Errorf( + "%w: member [%v] is both included and excluded in attempt", + ErrInvalidSigningAttemptPolicy, + memberIndex, + ) + } + + includedMembersSet[memberIndex] = struct{}{} + } + } else { + for i := 1; i <= request.GroupSize; i++ { + memberIndex := group.MemberIndex(i) + if _, excluded := excludedMembersSet[memberIndex]; !excluded { + includedMembersSet[memberIndex] = struct{}{} + } + } + } + + if len(includedMembersSet) == 0 { + if attempt != nil { + return nil, nil, fmt.Errorf( + "%w: included members set is empty", + ErrInvalidSigningAttemptPolicy, + ) + } + + return nil, nil, fmt.Errorf("included members set is empty") + } + + if attempt != nil { + if _, included := includedMembersSet[attempt.CoordinatorMemberIndex]; !included { + return nil, nil, fmt.Errorf( + "%w: attempt coordinator [%v] is not included", + ErrInvalidSigningAttemptPolicy, + attempt.CoordinatorMemberIndex, + ) + } + } + + includedMembersIndexes := make([]group.MemberIndex, 0, len(includedMembersSet)) + for memberIndex := range includedMembersSet { + includedMembersIndexes = append(includedMembersIndexes, memberIndex) + } + + sort.Slice(includedMembersIndexes, func(i, j int) bool { + return includedMembersIndexes[i] < includedMembersIndexes[j] + }) + + return includedMembersSet, includedMembersIndexes, nil +} + +func buildTaggedTBTCSignerRunDKGInputsForPayload( + payload *NativeTBTCSignerMaterialPayload, + request *NativeExecutionFFISigningRequest, + includedMembersIndexes []group.MemberIndex, +) ([]NativeTBTCSignerDKGParticipant, uint16, error) { + if payload != nil && + payload.KeyGroupSource == NativeTBTCSignerKeyGroupSourceDKGPersisted { + if len(payload.DKGParticipants) < 2 { + return nil, 0, fmt.Errorf( + "persisted tbtc-signer DKG participants are insufficient", + ) + } + if payload.DKGThreshold == 0 { + return nil, 0, fmt.Errorf( + "persisted tbtc-signer DKG threshold is zero", + ) + } + if int(payload.DKGThreshold) > len(payload.DKGParticipants) { + return nil, 0, fmt.Errorf( + "persisted tbtc-signer DKG threshold exceeds participant count: [%v] > [%v]", + payload.DKGThreshold, + len(payload.DKGParticipants), + ) + } + + participants := make( + []NativeTBTCSignerDKGParticipant, + len(payload.DKGParticipants), + ) + copy(participants, payload.DKGParticipants) + + return participants, payload.DKGThreshold, nil + } + + return buildTaggedTBTCSignerRunDKGInputsForIncludedMembers( + request, + includedMembersIndexes, + ) +} + +func buildTaggedTBTCSignerRunDKGInputsForIncludedMembers( + request *NativeExecutionFFISigningRequest, + includedMembersIndexes []group.MemberIndex, +) ([]NativeTBTCSignerDKGParticipant, uint16, error) { + if request == nil { + return nil, 0, fmt.Errorf("request is nil") + } + + if len(includedMembersIndexes) < 2 { + return nil, 0, fmt.Errorf("insufficient included members for DKG") + } + + threshold := request.DishonestThreshold + 1 + if threshold < 2 { + return nil, 0, fmt.Errorf("derived threshold is below minimum: [%v]", threshold) + } + + if threshold > len(includedMembersIndexes) { + return nil, 0, fmt.Errorf( + "derived threshold exceeds included members count: [%v] > [%v]", + threshold, + len(includedMembersIndexes), + ) + } + + participants := make([]NativeTBTCSignerDKGParticipant, 0, len(includedMembersIndexes)) + for _, memberIndex := range includedMembersIndexes { + if memberIndex == 0 { + return nil, 0, fmt.Errorf("included member index is zero") + } + + identifier := uint16(memberIndex) + participants = append( + participants, + NativeTBTCSignerDKGParticipant{ + Identifier: identifier, + PublicKeyHex: buildTaggedTBTCSignerDKGPlaceholderPublicKeyHex(identifier), + }, + ) + } + + return participants, uint16(threshold), nil +} + +func buildTaggedTBTCSignerDKGPlaceholderPublicKeyHex(identifier uint16) string { + // Transitional placeholder until canonical member public keys are available + // in the native signing request path. + return fmt.Sprintf("02%04x", identifier) +} + +// NativeTBTCSignerDKGPlaceholderPublicKeyHex returns the transitional +// placeholder public key used by tbtc-signer dealer-DKG requests. +func NativeTBTCSignerDKGPlaceholderPublicKeyHex(identifier uint16) string { + return buildTaggedTBTCSignerDKGPlaceholderPublicKeyHex(identifier) +} + +func buildTaggedTBTCSignerRoundKeyGroup( + payload *NativeTBTCSignerMaterialPayload, + dkgResult *NativeTBTCSignerDKGResult, +) (string, bool, error) { + if payload == nil { + return "", false, fmt.Errorf("tbtc-signer payload is nil") + } + + if dkgResult == nil { + return "", false, fmt.Errorf("tbtc-signer RunDKG result is nil") + } + + if dkgResult.KeyGroup == "" { + return "", false, fmt.Errorf("tbtc-signer RunDKG key group is empty") + } + + if payload.KeyGroup == dkgResult.KeyGroup { + return payload.KeyGroup, false, nil + } + + if payload.KeyGroupSource == NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey { + // Scaffold compatibility: legacy-wallet-pubkey key groups are + // placeholder-only and expected to diverge from coarse RunDKG output. + // Refuse the substitution by default so a production deployment that + // somehow ended up with placeholder material does not silently route + // signing through whatever key group the Rust side happens to return. + // The operator must explicitly opt into the scaffold path via + // AcceptScaffoldKeyGroupEnvVar; the env-var check is per-call (not + // cached) so flipping it off recovers fail-closed behavior without a + // restart. + if !AcceptScaffoldKeyGroupEnabled() { + return "", false, fmt.Errorf( + "tbtc-signer key group source %q is scaffold-era placeholder "+ + "material and may not be silently substituted with the "+ + "RunDKG output; set %s=true to opt in for local/CI use "+ + "only, never in production", + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + AcceptScaffoldKeyGroupEnvVar, + ) + } + return dkgResult.KeyGroup, true, nil + } + + return "", false, fmt.Errorf("tbtc-signer key group does not match RunDKG result") +} + +func isBuildTaggedTBTCSignerBootstrapVersion(version string) bool { + version = strings.TrimSpace(version) + if !strings.HasPrefix(version, buildTaggedTBTCSignerVersionPrefix) { + return false + } + + version = strings.TrimPrefix(version, buildTaggedTBTCSignerVersionPrefix) + coreVersion, prerelease, hasPrerelease := strings.Cut(version, "-") + if !hasPrerelease { + return false + } + + if prerelease != buildTaggedTBTCSignerBootstrapVersionPrerelease && + !strings.HasPrefix( + prerelease, + buildTaggedTBTCSignerBootstrapVersionPrerelease+".", + ) { + return false + } + + coreSegments := strings.Split(coreVersion, ".") + if len(coreSegments) != 3 { + return false + } + + // Bootstrap scaffold must be enabled only on 0.x.y pre-release builds. + if coreSegments[0] != "0" { + return false + } + + for _, segment := range coreSegments { + if segment == "" { + return false + } + + for _, character := range segment { + if character < '0' || character > '9' { + return false + } + } + } + + return true +} + +func executeBuildTaggedTBTCSignerBootstrapCoarseRound( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + keyGroup string, + nativeEngine NativeTBTCSignerEngine, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, +) error { + _, err := executeBuildTaggedTBTCSignerBootstrapCoarseRoundWithSignature( + ctx, + request, + keyGroup, + nativeEngine, + includedMembersSet, + includedMembersIndexes, + ) + + return err +} + +func executeBuildTaggedTBTCSignerBootstrapCoarseRoundWithSignature( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + keyGroup string, + nativeEngine NativeTBTCSignerEngine, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, +) ([]byte, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Message == nil { + return nil, fmt.Errorf("request message is nil") + } + + if nativeEngine == nil { + return nil, fmt.Errorf("native tbtc-signer engine is nil") + } + + if ctx == nil { + ctx = context.Background() + } + + if includedMembersSet == nil || len(includedMembersIndexes) == 0 { + var err error + includedMembersSet, includedMembersIndexes, err = includedMembersFromRequest(request) + if err != nil { + return nil, fmt.Errorf("cannot determine included members: [%w]", err) + } + } + + if _, ok := includedMembersSet[request.MemberIndex]; !ok { + return nil, fmt.Errorf( + "member [%v] not included in tbtc-signer signing attempt", + request.MemberIndex, + ) + } + + messageDigest, err := messageDigestFromBigInt(request.Message) + if err != nil { + return nil, fmt.Errorf("invalid request message digest: [%v]", err) + } + messageBytes := make([]byte, len(messageDigest)) + copy(messageBytes, messageDigest[:]) + + if request.MemberIndex == 0 { + return nil, fmt.Errorf("request member index is zero") + } + + signingParticipants, err := buildTaggedTBTCSignerSigningParticipants( + includedMembersIndexes, + ) + if err != nil { + return nil, fmt.Errorf("cannot derive signing participants: [%w]", err) + } + + roundState, err := nativeEngine.StartSignRound( + request.SessionID, + uint16(request.MemberIndex), + messageBytes, + keyGroup, + signingParticipants, + request.TaprootMerkleRoot, + ) + if err != nil { + return nil, fmt.Errorf("start sign round failed: [%w]", err) + } + + if roundState == nil { + return nil, fmt.Errorf("start sign round returned nil state") + } + + if roundState.RequiredContributions == 0 { + return nil, fmt.Errorf("start sign round required contributions are zero") + } + + if len(signingParticipants) > 0 { + if len(roundState.SigningParticipants) != len(signingParticipants) { + return nil, fmt.Errorf( + "start sign round returned unexpected signing participants count: [%v] != [%v]", + len(roundState.SigningParticipants), + len(signingParticipants), + ) + } + + for i := range signingParticipants { + if roundState.SigningParticipants[i] != signingParticipants[i] { + return nil, fmt.Errorf( + "start sign round returned unexpected signing participant at index [%d]: [%v] != [%v]", + i, + roundState.SigningParticipants[i], + signingParticipants[i], + ) + } + } + } + + roundContributions, err := buildTaggedTBTCSignerRoundContributions( + ctx, + request, + roundState, + includedMembersSet, + includedMembersIndexes, + ) + if err != nil { + return nil, fmt.Errorf("cannot collect round contributions: [%w]", err) + } + + if len(roundContributions) < int(roundState.RequiredContributions) { + return nil, fmt.Errorf( + "insufficient round contributions: [%v] < [%v]", + len(roundContributions), + roundState.RequiredContributions, + ) + } + + signature, err := nativeEngine.FinalizeSignRound( + request.SessionID, + roundContributions, + request.TaprootMerkleRoot, + ) + if err != nil { + return nil, fmt.Errorf("finalize sign round failed: [%w]", err) + } + + if len(signature) == 0 { + return nil, fmt.Errorf("finalize sign round returned empty signature") + } + + return signature, nil +} + +func decodeBuildTaggedTBTCSignerSignature(signature []byte) (*frost.Signature, error) { + if len(signature) == 0 { + return nil, fmt.Errorf("signature is empty") + } + + // Unmarshal validates length and splits the wire value into R/S. The + // tbtc-signer material carries a key-group handle rather than the x-only + // output key, so this layer can only enforce canonical Schnorr encoding. + // Key-bound verification happens downstream when the wallet output key is + // available. + result := &frost.Signature{} + if err := result.Unmarshal(signature); err != nil { + return nil, fmt.Errorf("invalid frost signature bytes: [%w]", err) + } + + serialized := result.Serialize() + if _, err := schnorr.ParseSignature(serialized[:]); err != nil { + return nil, fmt.Errorf("non-canonical BIP-340 signature bytes: [%w]", err) + } + + return result, nil +} + +func buildTaggedTBTCSignerSigningParticipants( + includedMembersIndexes []group.MemberIndex, +) ([]uint16, error) { + if len(includedMembersIndexes) == 0 { + return nil, fmt.Errorf("included members are empty") + } + + signingParticipants := make([]uint16, 0, len(includedMembersIndexes)) + seenParticipants := make(map[uint16]struct{}, len(includedMembersIndexes)) + + for _, memberIndex := range includedMembersIndexes { + if memberIndex == 0 { + return nil, fmt.Errorf("included member index is zero") + } + + participant := uint16(memberIndex) + if _, ok := seenParticipants[participant]; ok { + return nil, fmt.Errorf("duplicate included member index: [%v]", memberIndex) + } + + seenParticipants[participant] = struct{}{} + signingParticipants = append(signingParticipants, participant) + } + + return signingParticipants, nil +} + +func buildTaggedTBTCSignerRoundContributions( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + roundState *NativeTBTCSignerRoundState, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, +) ([]NativeTBTCSignerRoundContribution, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Channel == nil { + // Compatibility path for unit tests that do not attach a broadcast + // channel. Runtime signer flows provide a channel and use contribution + // exchange with peers. + return buildTaggedTBTCSignerSyntheticRoundContributions( + roundState, + includedMembersIndexes, + ) + } + + ownContribution, err := buildTaggedTBTCSignerOwnRoundContribution( + request, + roundState, + ) + if err != nil { + return nil, fmt.Errorf("cannot build own round contribution: [%w]", err) + } + + roundContributionMessage := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: uint32(request.MemberIndex), + SessionIDValue: request.SessionID, + ContributionIdentifier: ownContribution.Identifier, + ContributionData: append([]byte{}, ownContribution.Data...), + } + setMessageAttemptContextHashIfBound(roundContributionMessage, request.SessionID, request.MemberIndex) + + if err := request.Channel.Send( + ctx, + roundContributionMessage, + net.BackoffRetransmissionStrategy, + ); err != nil { + return nil, fmt.Errorf("cannot send round contribution message: [%w]", err) + } + + // RFC-21 Phase 4.2/4.3: recorder comes from the roast-retry + // registry; deferred submission pushes the snapshot into + // Coordinator.RecordEvidence at end-of-collect. NoOp fallback + // when nothing is registered preserves Phase 2 receive + // semantics. + contributionsRecorder := roastRetryRecorderForCollect() + defer submitSnapshotIfActive(request.SessionID, request.MemberIndex, contributionsRecorder) + peerMessages, err := collectBuildTaggedTBTCSignerRoundContributionMessages( + ctx, + request, + includedMembersSet, + includedMembersIndexes, + contributionsRecorder, + ) + if err != nil { + return nil, err + } + + contributionsBySender := map[group.MemberIndex]NativeTBTCSignerRoundContribution{ + request.MemberIndex: ownContribution, + } + + for senderID, message := range peerMessages { + contributionsBySender[senderID] = NativeTBTCSignerRoundContribution{ + Identifier: message.ContributionIdentifier, + Data: append([]byte{}, message.ContributionData...), + } + } + + orderedContributions := make( + []NativeTBTCSignerRoundContribution, + 0, + len(includedMembersIndexes), + ) + for _, memberIndex := range includedMembersIndexes { + contribution, ok := contributionsBySender[memberIndex] + if !ok { + return nil, fmt.Errorf("missing contribution from member [%v]", memberIndex) + } + + orderedContributions = append(orderedContributions, contribution) + } + + return orderedContributions, nil +} + +func buildTaggedTBTCSignerOwnRoundContribution( + request *NativeExecutionFFISigningRequest, + roundState *NativeTBTCSignerRoundState, +) (NativeTBTCSignerRoundContribution, error) { + if request == nil { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf("request is nil") + } + + if request.MemberIndex == 0 { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf("request member index is zero") + } + + if roundState != nil && roundState.OwnContribution != nil { + ownContribution := roundState.OwnContribution + if ownContribution.Identifier == 0 { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf( + "round state own contribution identifier is zero", + ) + } + + if len(ownContribution.Data) == 0 { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf( + "round state own contribution data is empty", + ) + } + + if ownContribution.Identifier != uint16(request.MemberIndex) { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf( + "round state own contribution identifier [%v] does not match member index [%v]", + ownContribution.Identifier, + request.MemberIndex, + ) + } + + return NativeTBTCSignerRoundContribution{ + Identifier: ownContribution.Identifier, + Data: append([]byte{}, ownContribution.Data...), + }, nil + } + + ownContributions, err := buildTaggedTBTCSignerSyntheticRoundContributions( + roundState, + []group.MemberIndex{request.MemberIndex}, + ) + if err != nil { + return NativeTBTCSignerRoundContribution{}, err + } + + if len(ownContributions) != 1 { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf( + "unexpected own contribution count: [%v]", + len(ownContributions), + ) + } + + return ownContributions[0], nil +} + +func collectBuildTaggedTBTCSignerRoundContributionMessages( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, + evidence attempt.EvidenceRecorder, +) (map[group.MemberIndex]*buildTaggedTBTCSignerRoundContributionMessage, error) { + expectedMessagesCount := len(includedMembersIndexes) - 1 + if expectedMessagesCount <= 0 { + return map[group.MemberIndex]*buildTaggedTBTCSignerRoundContributionMessage{}, nil + } + + recvCtx, cancelRecvCtx := context.WithCancel(ctx) + defer cancelRecvCtx() + + messageChan := make( + chan *buildTaggedTBTCSignerRoundContributionMessage, + expectedMessagesCount*4+1, + ) + + request.Channel.Recv(recvCtx, func(message net.Message) { + payload, ok := message.Payload().(*buildTaggedTBTCSignerRoundContributionMessage) + if !ok { + return + } + + if !shouldAcceptNativeFROSTMessage( + request, + includedMembersSet, + payload.SenderID(), + payload.SessionID(), + message.SenderPublicKey(), + ) { + evidence.RecordReject(payload.SenderID(), "validation_gate_rejected") + return + } + + if err := verifyMessageAttemptContextHash(payload, request.SessionID, request.MemberIndex); err != nil { + evidence.RecordReject(payload.SenderID(), "attempt_context_hash_mismatch") + return + } + + _ = enqueueOrRecordOverflow(payload, messageChan, evidence) + }) + + receivedMessages := make( + map[group.MemberIndex]*buildTaggedTBTCSignerRoundContributionMessage, + ) + for len(receivedMessages) < expectedMessagesCount { + select { + case <-ctx.Done(): + return nil, fmt.Errorf( + "tbtc-signer round contribution collection interrupted: [%w]", + ctx.Err(), + ) + + case message := <-messageChan: + // First-write-wins / equal-or-reject. A peer that retransmits the + // same contribution is idempotent; a peer that mutates its own + // contribution after the first send is a ROAST evidence concern + // and must not be allowed to overwrite the persisted view. + senderID := message.SenderID() + if existing, ok := receivedMessages[senderID]; ok { + if !buildTaggedTBTCSignerRoundContributionMessagesEqual( + existing, + message, + ) { + evidence.RecordConflict(senderID) + protocolLogger.Warnf( + "dropping conflicting tbtc-signer round contribution "+ + "from sender [%d]; first-write-wins keeps the "+ + "originally accepted contribution", + senderID, + ) + } + continue + } + receivedMessages[senderID] = message + } + } + + return receivedMessages, nil +} + +func buildTaggedTBTCSignerRoundContributionMessagesEqual( + left, right *buildTaggedTBTCSignerRoundContributionMessage, +) bool { + if left == nil || right == nil { + return left == right + } + return left.SenderIDValue == right.SenderIDValue && + left.SessionIDValue == right.SessionIDValue && + left.ContributionIdentifier == right.ContributionIdentifier && + bytes.Equal(left.ContributionData, right.ContributionData) && + bytes.Equal(left.AttemptContextHash, right.AttemptContextHash) +} + +func buildTaggedTBTCSignerSyntheticRoundContributions( + roundState *NativeTBTCSignerRoundState, + includedMembersIndexes []group.MemberIndex, +) ([]NativeTBTCSignerRoundContribution, error) { + if roundState == nil { + return nil, fmt.Errorf("round state is nil") + } + + if roundState.SessionID == "" { + return nil, fmt.Errorf("round state session ID is empty") + } + + if roundState.RoundID == "" { + return nil, fmt.Errorf("round state round ID is empty") + } + + if roundState.MessageDigestHex == "" { + return nil, fmt.Errorf("round state message digest is empty") + } + + contributions := make( + []NativeTBTCSignerRoundContribution, + 0, + len(includedMembersIndexes), + ) + + for _, memberIndex := range includedMembersIndexes { + if memberIndex == 0 { + return nil, fmt.Errorf("included member index is zero") + } + + identifier := uint16(memberIndex) + seed := fmt.Sprintf( + "%s:%s:%s:%s:%d", + buildTaggedTBTCSignerSyntheticContributionDomain, + roundState.SessionID, + roundState.RoundID, + roundState.MessageDigestHex, + identifier, + ) + shareDigest := sha256.Sum256([]byte(seed)) + + contributions = append( + contributions, + NativeTBTCSignerRoundContribution{ + Identifier: identifier, + Data: append([]byte{}, shareDigest[:]...), + }, + ) + } + + return contributions, nil +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) signWithLegacyTECDSABridge( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + privateKeyShare, err := decodeBuildTaggedLegacyPrivateKeyShare( + request.SignerMaterial, + ) + if err != nil { + return nil, err + } + + return btlcnnefsp.signWithLegacyPrivateKeyShare( + ctx, + logger, + request, + privateKeyShare, + ) +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) signWithLegacyPrivateKeyShare( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, + privateKeyShare *tecdsa.PrivateKeyShare, +) (*frost.Signature, error) { + if request.TaprootMerkleRoot != nil { + return nil, fmt.Errorf( + "%w: taproot tweaked signing requires native FROST signer support", + ErrNativeCryptographyUnavailable, + ) + } + + if privateKeyShare == nil { + return nil, fmt.Errorf("legacy private key share is nil") + } + + excludedMembersIndexes := []group.MemberIndex{} + if request.Attempt != nil { + excludedMembersIndexes = request.Attempt.ExcludedMembersIndexes + } + + legacyResult, err := legacySigning.Execute( + ctx, + logger, + request.Message, + request.SessionID, + request.MemberIndex, + privateKeyShare, + request.GroupSize, + request.DishonestThreshold, + excludedMembersIndexes, + request.Channel, + request.MembershipValidator, + ) + if err != nil { + return nil, err + } + + return FromTECDSASignature(legacyResult.Signature) +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) fallbackTBTCSignerLegacySigning( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, + legacyPrivateKeyShare *tecdsa.PrivateKeyShare, + reason string, + keyGroupSource string, +) (*frost.Signature, error) { + emitNativeTBTCSignerFallbackEvent( + NativeTBTCSignerFallbackEvent{ + SessionID: request.SessionID, + Reason: reason, + KeyGroupSource: keyGroupSource, + LegacyPrivateKeyShareExists: legacyPrivateKeyShare != nil, + }, + ) + + if legacyPrivateKeyShare == nil { + return nil, fmt.Errorf("%w: %s", ErrNativeCryptographyUnavailable, reason) + } + + if logger != nil { + logger.Warnf( + "falling back to legacy tECDSA signer path for tbtc-signer payload: [%s]", + reason, + ) + } + + return btlcnnefsp.signWithLegacyPrivateKeyShare( + ctx, + logger, + request, + legacyPrivateKeyShare, + ) +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + registerBuildTaggedTBTCSignerUnmarshallers(channel) + legacySigning.RegisterUnmarshallers(channel) +} + +func registerBuildTaggedTBTCSignerUnmarshallers(channel net.BroadcastChannel) { + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &buildTaggedTBTCSignerRoundContributionMessage{} + }) +} + +func decodeBuildTaggedLegacyPrivateKeyShare( + signerMaterial *NativeSignerMaterial, +) (*tecdsa.PrivateKeyShare, error) { + if signerMaterial == nil { + return nil, fmt.Errorf( + "%w: signer material is nil", + ErrNativeCryptographyUnavailable, + ) + } + + if signerMaterial.Format != NativeSignerMaterialFormatFrostUniFFIV1 { + return nil, fmt.Errorf( + "%w: unsupported signer material format: [%s]", + ErrNativeCryptographyUnavailable, + signerMaterial.Format, + ) + } + + if len(signerMaterial.Payload) == 0 { + return nil, fmt.Errorf( + "%w: signer material payload is empty", + ErrNativeCryptographyUnavailable, + ) + } + + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(signerMaterial.Payload); err != nil { + return nil, fmt.Errorf( + "%w: cannot unmarshal signer material payload: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + return privateKeyShare, nil +} + +func decodeBuildTaggedTBTCSignerMaterialPayload( + signerMaterial *NativeSignerMaterial, +) (*NativeTBTCSignerMaterialPayload, error) { + if signerMaterial == nil { + return nil, fmt.Errorf( + "%w: signer material is nil", + ErrNativeCryptographyUnavailable, + ) + } + + if signerMaterial.Format != NativeSignerMaterialFormatFrostTBTCSignerV1 { + return nil, fmt.Errorf( + "%w: unsupported signer material format: [%s]", + ErrNativeCryptographyUnavailable, + signerMaterial.Format, + ) + } + + if len(signerMaterial.Payload) == 0 { + return nil, fmt.Errorf( + "%w: signer material payload is empty", + ErrNativeCryptographyUnavailable, + ) + } + + var payload NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(signerMaterial.Payload, &payload); err != nil { + return nil, fmt.Errorf( + "%w: cannot unmarshal tbtc-signer payload: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if payload.KeyGroup == "" { + return nil, fmt.Errorf( + "%w: tbtc-signer key group is empty", + ErrNativeCryptographyUnavailable, + ) + } + + return &payload, nil +} + +func decodeBuildTaggedTBTCSignerKeyGroup( + signerMaterial *NativeSignerMaterial, +) (string, error) { + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) + if err != nil { + return "", err + } + + return payload.KeyGroup, nil +} + +func shouldAcceptNativeFROSTMessage( + request *NativeExecutionFFISigningRequest, + includedMembersSet map[group.MemberIndex]struct{}, + senderID group.MemberIndex, + sessionID string, + senderPublicKey []byte, +) bool { + if senderID == 0 { + return false + } + + if senderID == request.MemberIndex { + return false + } + + if sessionID != request.SessionID { + return false + } + + if _, included := includedMembersSet[senderID]; !included { + return false + } + + if request.MembershipValidator == nil { + return true + } + + return request.MembershipValidator.IsValidMembership(senderID, senderPublicKey) +} + +func decodeBuildTaggedTBTCSignerLegacyPrivateKeyShare( + payload *NativeTBTCSignerMaterialPayload, +) (*tecdsa.PrivateKeyShare, error) { + if payload == nil || payload.LegacyPrivateKeyShareHex == "" { + return nil, nil + } + + legacyPrivateKeySharePayload, err := hex.DecodeString(payload.LegacyPrivateKeyShareHex) + if err != nil { + return nil, fmt.Errorf( + "%w: cannot decode tbtc-signer legacy private key share: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(legacyPrivateKeySharePayload); err != nil { + return nil, fmt.Errorf( + "%w: cannot unmarshal tbtc-signer legacy private key share: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + return privateKeyShare, nil +} + +func runNativeTBTCSignerDKG( + nativeEngine NativeTBTCSignerEngine, + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + dkgSeedHex string, +) (*NativeTBTCSignerDKGResult, error) { + if dkgSeedHex == "" { + return nativeEngine.RunDKG(sessionID, participants, threshold) + } + + seededEngine, ok := nativeEngine.(NativeTBTCSignerSeededDKGEngine) + if !ok { + return nil, fmt.Errorf( + "native tbtc-signer engine does not support seeded RunDKG", + ) + } + + return seededEngine.RunDKGWithSeed( + sessionID, + participants, + threshold, + dkgSeedHex, + ) +} diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go new file mode 100644 index 0000000000..0426b12cc2 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go @@ -0,0 +1,3245 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/internal/tecdsatest" + "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +type mockBuildTaggedTBTCSignerEngine struct { + runDKGCalled bool + runDKGSessionID string + runDKGParticipants []NativeTBTCSignerDKGParticipant + runDKGThreshold uint16 + runDKGResult *NativeTBTCSignerDKGResult + runDKGErr error + runDKGFn func( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + ) (*NativeTBTCSignerDKGResult, error) + version string + versionErr error + startCalled bool + startSessionID string + startMemberID uint16 + startMessage []byte + startKeyGroup string + startSigningParticipants []uint16 + startSignRoundFn func( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + taprootMerkleRoot *[32]byte, + ) (*NativeTBTCSignerRoundState, error) + startTaprootMerkleRoot *[32]byte + startRoundState *NativeTBTCSignerRoundState + startErr error + finalizeCalled bool + finalizeSessionID string + finalizeTaprootMerkleRoot *[32]byte + finalizeInputs []NativeTBTCSignerRoundContribution + finalizeSignature []byte + finalizeErr error +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*NativeTBTCSignerDKGResult, error) { + mbttse.runDKGCalled = true + mbttse.runDKGSessionID = sessionID + mbttse.runDKGParticipants = append( + []NativeTBTCSignerDKGParticipant{}, + participants..., + ) + mbttse.runDKGThreshold = threshold + + if mbttse.runDKGErr != nil { + return nil, mbttse.runDKGErr + } + + if mbttse.runDKGFn != nil { + return mbttse.runDKGFn(sessionID, participants, threshold) + } + + if mbttse.runDKGResult != nil { + return mbttse.runDKGResult, nil + } + + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) Version() (string, error) { + if mbttse.versionErr != nil { + return "", mbttse.versionErr + } + + return mbttse.version, nil +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + taprootMerkleRoot *[32]byte, +) (*NativeTBTCSignerRoundState, error) { + mbttse.startCalled = true + mbttse.startSessionID = sessionID + mbttse.startMemberID = memberIdentifier + mbttse.startMessage = append([]byte{}, message...) + mbttse.startKeyGroup = keyGroup + mbttse.startSigningParticipants = append([]uint16{}, signingParticipants...) + mbttse.startTaprootMerkleRoot = cloneTestTaprootMerkleRoot(taprootMerkleRoot) + + if mbttse.startErr != nil { + return nil, mbttse.startErr + } + + if mbttse.startSignRoundFn != nil { + return mbttse.startSignRoundFn( + sessionID, + memberIdentifier, + message, + keyGroup, + signingParticipants, + taprootMerkleRoot, + ) + } + + if mbttse.startRoundState != nil { + if len(mbttse.startRoundState.SigningParticipants) == 0 { + mbttse.startRoundState.SigningParticipants = append( + []uint16{}, + signingParticipants..., + ) + } + + return mbttse.startRoundState, nil + } + + return &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "00", + SigningParticipants: append([]uint16{}, signingParticipants...), + }, nil +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) FinalizeSignRound( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + mbttse.finalizeCalled = true + mbttse.finalizeSessionID = sessionID + mbttse.finalizeTaprootMerkleRoot = cloneTestTaprootMerkleRoot(taprootMerkleRoot) + mbttse.finalizeInputs = append( + []NativeTBTCSignerRoundContribution{}, + roundContributions..., + ) + + if mbttse.finalizeErr != nil { + return nil, mbttse.finalizeErr + } + + if len(mbttse.finalizeSignature) > 0 { + return append([]byte{}, mbttse.finalizeSignature...), nil + } + + return []byte{0xaa}, nil +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + return nil, errors.New("not implemented") +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) VerifySignatureShare( + sessionID string, + signingPackage []byte, + signatureShare []byte, + memberIdentifier uint16, + taprootMerkleRoot *[32]byte, +) (NativeShareVerificationVerdict, error) { + return NativeShareVerdictIndeterminate, errors.New("not implemented") +} + +func cloneTestTaprootMerkleRoot(taprootMerkleRoot *[32]byte) *[32]byte { + if taprootMerkleRoot == nil { + return nil + } + + result := new([32]byte) + copy(result[:], taprootMerkleRoot[:]) + + return result +} + +type deterministicBuildTaggedTBTCSignerBootstrapRoundEngine struct { + roundState *NativeTBTCSignerRoundState + finalizeMutex sync.Mutex + finalizeCalls int + finalizeInput []NativeTBTCSignerRoundContribution +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*NativeTBTCSignerDKGResult, error) { + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) StartSignRound( + sessionID string, + memberIdentifier uint16, + _ []byte, + _ string, + signingParticipants []uint16, + _ *[32]byte, +) (*NativeTBTCSignerRoundState, error) { + if dbttsbre.roundState != nil { + if dbttsbre.roundState.OwnContribution == nil { + dbttsbre.roundState.OwnContribution = &NativeTBTCSignerRoundContribution{ + Identifier: memberIdentifier, + Data: []byte{byte(memberIdentifier), 0xab}, + } + } + + if len(dbttsbre.roundState.SigningParticipants) == 0 { + dbttsbre.roundState.SigningParticipants = append( + []uint16{}, + signingParticipants..., + ) + } + + return dbttsbre.roundState, nil + } + + if len(signingParticipants) == 0 { + signingParticipants = []uint16{memberIdentifier} + } + + return &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "00", + SigningParticipants: append([]uint16{}, signingParticipants...), + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: memberIdentifier, + Data: []byte{byte(memberIdentifier), 0xab}, + }, + }, nil +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) FinalizeSignRound( + _ string, + roundContributions []NativeTBTCSignerRoundContribution, + _ *[32]byte, +) ([]byte, error) { + dbttsbre.finalizeMutex.Lock() + defer dbttsbre.finalizeMutex.Unlock() + + dbttsbre.finalizeCalls++ + dbttsbre.finalizeInput = append( + []NativeTBTCSignerRoundContribution{}, + roundContributions..., + ) + + return []byte{0xaa}, nil +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + return nil, errors.New("not implemented") +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) VerifySignatureShare( + sessionID string, + signingPackage []byte, + signatureShare []byte, + memberIdentifier uint16, + taprootMerkleRoot *[32]byte, +) (NativeShareVerificationVerdict, error) { + return NativeShareVerdictIndeterminate, errors.New("not implemented") +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) finalizeInputs() []NativeTBTCSignerRoundContribution { + dbttsbre.finalizeMutex.Lock() + defer dbttsbre.finalizeMutex.Unlock() + + return append([]NativeTBTCSignerRoundContribution{}, dbttsbre.finalizeInput...) +} + +func buildTaggedTBTCSignerValidTestSignature(seed byte) []byte { + signature := make([]byte, 64) + for i := range signature { + signature[i] = seed + byte(i) + } + + return signature +} + +func TestDecodeBuildTaggedTBTCSignerSignatureRejectsNonCanonicalBIP340( + t *testing.T, +) { + _, err := decodeBuildTaggedTBTCSignerSignature(bytes.Repeat([]byte{0xff}, 64)) + if err == nil { + t.Fatal("expected non-canonical BIP-340 signature bytes to be rejected") + } + if !strings.Contains(err.Error(), "non-canonical BIP-340 signature bytes") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_ValidatesRequest( + t *testing.T, +) { + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err := primitive.Sign(nil, nil, nil) + if err == nil { + t.Fatal("expected error") + } + + if err.Error() != "request is nil" { + t.Fatalf( + "unexpected error\nexpected: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_ValidatesMessage( + t *testing.T, +) { + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0x01}, + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if err.Error() != "request message is nil" { + t.Fatalf( + "unexpected error\nexpected: [%s]\nactual: [%v]", + "request message is nil", + err, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_RejectsUnsupportedUniFFIV2Material( + t *testing.T, +) { + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: []byte{0x01}, + }, + }) + if err == nil { + t.Fatal("expected unsupported material error") + } + if !errors.Is(err, ErrUnsupportedSignerMaterialFormat) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrUnsupportedSignerMaterialFormat, + err, + ) + } + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unsupported signer material should not be reported as unavailable native cryptography: [%v]", + err, + ) + } +} + +func TestDecodeBuildTaggedLegacyPrivateKeyShare(t *testing.T) { + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(5) + if err != nil { + t.Fatalf("failed loading key share fixtures: [%v]", err) + } + + expectedPrivateKeyShare := tecdsa.NewPrivateKeyShare(fixtures[0]) + expectedPayload, err := expectedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling private key share: [%v]", err) + } + + decodedPrivateKeyShare, err := decodeBuildTaggedLegacyPrivateKeyShare( + &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: expectedPayload, + }, + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + actualPayload, err := decodedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling decoded private key share: [%v]", err) + } + + if !bytes.Equal(expectedPayload, actualPayload) { + t.Fatalf( + "unexpected decoded private key share\nexpected: [%x]\nactual: [%x]", + expectedPayload, + actualPayload, + ) + } +} + +func TestDecodeBuildTaggedLegacyPrivateKeyShare_RejectsInvalidMaterial( + t *testing.T, +) { + testCases := []struct { + name string + signerMaterial *NativeSignerMaterial + }{ + { + name: "nil signer material", + signerMaterial: nil, + }, + { + name: "unsupported format", + signerMaterial: &NativeSignerMaterial{ + Format: "other", + Payload: []byte{0x01}, + }, + }, + { + name: "empty payload", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + }, + }, + { + name: "invalid payload", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: big.NewInt(123).Bytes(), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := decodeBuildTaggedLegacyPrivateKeyShare(tc.signerMaterial) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerKeyGroup(t *testing.T) { + keyGroup, err := decodeBuildTaggedTBTCSignerKeyGroup(&NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1"}`), + }) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if keyGroup != "group-1" { + t.Fatalf( + "unexpected key group\nexpected: [%v]\nactual: [%v]", + "group-1", + keyGroup, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerKeyGroup_RejectsInvalidMaterial( + t *testing.T, +) { + testCases := []struct { + name string + signerMaterial *NativeSignerMaterial + }{ + { + name: "nil signer material", + signerMaterial: nil, + }, + { + name: "unsupported format", + signerMaterial: &NativeSignerMaterial{ + Format: "other", + Payload: []byte(`{"keyGroup":"group-1"}`), + }, + }, + { + name: "empty payload", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + }, + }, + { + name: "invalid payload", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":`), + }, + }, + { + name: "empty key group", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":""}`), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := decodeBuildTaggedTBTCSignerKeyGroup(tc.signerMaterial) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerLegacyPrivateKeyShare(t *testing.T) { + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(5) + if err != nil { + t.Fatalf("failed loading key share fixtures: [%v]", err) + } + + expectedPrivateKeyShare := tecdsa.NewPrivateKeyShare(fixtures[0]) + expectedPayload, err := expectedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling private key share: [%v]", err) + } + + decodedPrivateKeyShare, err := decodeBuildTaggedTBTCSignerLegacyPrivateKeyShare( + &NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + LegacyPrivateKeyShareHex: hex.EncodeToString(expectedPayload), + }, + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if decodedPrivateKeyShare == nil { + t.Fatal("expected decoded private key share") + } + + actualPayload, err := decodedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling decoded private key share: [%v]", err) + } + + if !bytes.Equal(expectedPayload, actualPayload) { + t.Fatalf( + "unexpected decoded private key share\nexpected: [%x]\nactual: [%x]", + expectedPayload, + actualPayload, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerLegacyPrivateKeyShare_RejectsInvalidPayload( + t *testing.T, +) { + testCases := []struct { + name string + payload *NativeTBTCSignerMaterialPayload + expectError bool + }{ + { + name: "nil payload", + payload: nil, + expectError: false, + }, + { + name: "empty legacy private key share", + payload: &NativeTBTCSignerMaterialPayload{}, + expectError: false, + }, + { + name: "invalid hex", + payload: &NativeTBTCSignerMaterialPayload{ + LegacyPrivateKeyShareHex: "zz", + }, + expectError: true, + }, + { + name: "invalid private key share payload", + payload: &NativeTBTCSignerMaterialPayload{ + LegacyPrivateKeyShareHex: hex.EncodeToString(big.NewInt(123).Bytes()), + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + decoded, err := decodeBuildTaggedTBTCSignerLegacyPrivateKeyShare(tc.payload) + + if tc.expectError { + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + return + } + + if err != nil { + t.Fatalf("expected nil error, got: [%v]", err) + } + + if decoded != nil { + t.Fatalf("expected nil decoded private key share, got: [%v]", decoded) + } + }) + } +} + +func TestBuildTaggedTBTCSignerRunDKGInputs(t *testing.T) { + participants, threshold, err := buildTaggedTBTCSignerRunDKGInputs( + &NativeExecutionFFISigningRequest{ + GroupSize: 5, + DishonestThreshold: 2, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 3, 5}, + }, + }, + ) + if err != nil { + t.Fatalf("unexpected RunDKG inputs error: [%v]", err) + } + + if threshold != 3 { + t.Fatalf( + "unexpected threshold\nexpected: [%v]\nactual: [%v]", + 3, + threshold, + ) + } + + if len(participants) != 3 { + t.Fatalf( + "unexpected participants count\nexpected: [%v]\nactual: [%v]", + 3, + len(participants), + ) + } + + expectedIdentifiers := []uint16{1, 3, 5} + expectedPublicKeys := []string{"020001", "020003", "020005"} + + for i := range participants { + if participants[i].Identifier != expectedIdentifiers[i] { + t.Fatalf( + "unexpected participant identifier at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedIdentifiers[i], + participants[i].Identifier, + ) + } + + if participants[i].PublicKeyHex != expectedPublicKeys[i] { + t.Fatalf( + "unexpected participant public key at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedPublicKeys[i], + participants[i].PublicKeyHex, + ) + } + } +} + +func TestBuildTaggedTBTCSignerRunDKGInputsForPayload_UsesPersistedDKGInputs( + t *testing.T, +) { + persistedParticipants := []NativeTBTCSignerDKGParticipant{ + {Identifier: 1, PublicKeyHex: "020001"}, + {Identifier: 2, PublicKeyHex: "020002"}, + {Identifier: 3, PublicKeyHex: "020003"}, + } + + participants, threshold, err := buildTaggedTBTCSignerRunDKGInputsForPayload( + &NativeTBTCSignerMaterialPayload{ + KeyGroupSource: NativeTBTCSignerKeyGroupSourceDKGPersisted, + DKGParticipants: persistedParticipants, + DKGThreshold: 2, + }, + &NativeExecutionFFISigningRequest{ + GroupSize: 3, + DishonestThreshold: 1, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 3}, + }, + }, + []group.MemberIndex{1, 3}, + ) + if err != nil { + t.Fatalf("unexpected RunDKG inputs error: [%v]", err) + } + + if threshold != 2 { + t.Fatalf("unexpected threshold: [%v]", threshold) + } + if len(participants) != len(persistedParticipants) { + t.Fatalf( + "unexpected participants count\nexpected: [%v]\nactual: [%v]", + len(persistedParticipants), + len(participants), + ) + } + for i := range participants { + if participants[i] != persistedParticipants[i] { + t.Fatalf( + "unexpected participant at index [%d]\nexpected: [%+v]\nactual: [%+v]", + i, + persistedParticipants[i], + participants[i], + ) + } + } +} + +func TestBuildTaggedTBTCSignerRunDKGInputs_RejectsInvalidRequest(t *testing.T) { + testCases := []struct { + name string + request *NativeExecutionFFISigningRequest + }{ + { + name: "zero group size", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 0, + DishonestThreshold: 1, + }, + }, + { + name: "derived threshold exceeds participants", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 2, + DishonestThreshold: 2, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := buildTaggedTBTCSignerRunDKGInputs(tc.request) + if err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestIncludedMembersFromRequest_RejectsInvalidAttemptPolicy(t *testing.T) { + testCases := []struct { + name string + request *NativeExecutionFFISigningRequest + errFragment string + }{ + { + name: "zero attempt number", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 3, + Attempt: &Attempt{ + Number: 0, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + errFragment: "attempt number is zero", + }, + { + name: "zero coordinator", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 3, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 0, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + errFragment: "attempt coordinator member index is zero", + }, + { + name: "coordinator not included", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 3, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 3, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + errFragment: "attempt coordinator [3] is not included", + }, + { + name: "member both included and excluded", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 3, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + ExcludedMembersIndexes: []group.MemberIndex{2}, + }, + }, + errFragment: "member [2] is both included and excluded in attempt", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := includedMembersFromRequest(tc.request) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), tc.errFragment) { + t.Fatalf( + "unexpected error\nexpected to contain: [%v]\nactual: [%v]", + tc.errFragment, + err, + ) + } + }) + } +} + +func TestBuildTaggedTBTCSignerSyntheticRoundContributions(t *testing.T) { + roundState := &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + MessageDigestHex: "aabbccdd", + } + + contributionsFirst, err := buildTaggedTBTCSignerSyntheticRoundContributions( + roundState, + []group.MemberIndex{1, 2, 3}, + ) + if err != nil { + t.Fatalf("unexpected synthetic contribution error: [%v]", err) + } + + contributionsSecond, err := buildTaggedTBTCSignerSyntheticRoundContributions( + roundState, + []group.MemberIndex{1, 2, 3}, + ) + if err != nil { + t.Fatalf("unexpected synthetic contribution error: [%v]", err) + } + + if len(contributionsFirst) != 3 { + t.Fatalf( + "unexpected contribution count\nexpected: [%v]\nactual: [%v]", + 3, + len(contributionsFirst), + ) + } + + expectedIdentifiers := []uint16{1, 2, 3} + for i, contribution := range contributionsFirst { + if contribution.Identifier != expectedIdentifiers[i] { + t.Fatalf( + "unexpected contribution identifier at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedIdentifiers[i], + contribution.Identifier, + ) + } + + if len(contribution.Data) != 32 { + t.Fatalf( + "unexpected contribution size at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + 32, + len(contribution.Data), + ) + } + + if !bytes.Equal(contribution.Data, contributionsSecond[i].Data) { + t.Fatalf("expected deterministic contribution at index [%d]", i) + } + } + + roundStateChanged := &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-2", + MessageDigestHex: "aabbccdd", + } + contributionsChanged, err := buildTaggedTBTCSignerSyntheticRoundContributions( + roundStateChanged, + []group.MemberIndex{1, 2, 3}, + ) + if err != nil { + t.Fatalf("unexpected synthetic contribution error: [%v]", err) + } + + if bytes.Equal(contributionsFirst[0].Data, contributionsChanged[0].Data) { + t.Fatal("expected contribution data to change when round metadata changes") + } +} + +func TestBuildTaggedTBTCSignerSyntheticRoundContributions_RejectsInvalidInput(t *testing.T) { + testCases := []struct { + name string + roundState *NativeTBTCSignerRoundState + members []group.MemberIndex + }{ + { + name: "nil round state", + roundState: nil, + members: []group.MemberIndex{1, 2}, + }, + { + name: "empty session id", + roundState: &NativeTBTCSignerRoundState{ + SessionID: "", + RoundID: "round-1", + MessageDigestHex: "aa", + }, + members: []group.MemberIndex{1, 2}, + }, + { + name: "empty round id", + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "", + MessageDigestHex: "aa", + }, + members: []group.MemberIndex{1, 2}, + }, + { + name: "empty message digest", + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + MessageDigestHex: "", + }, + members: []group.MemberIndex{1, 2}, + }, + { + name: "zero member index", + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + MessageDigestHex: "aa", + }, + members: []group.MemberIndex{0, 2}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := buildTaggedTBTCSignerSyntheticRoundContributions( + tc.roundState, + tc.members, + ) + if err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestExecuteBuildTaggedTBTCSignerBootstrapCoarseRound_ExchangesContributionsOverChannel( + t *testing.T, +) { + provider := local.Connect() + channel, err := provider.BroadcastChannelFor("tbtc-signer-bootstrap-round-plumbing-test") + if err != nil { + t.Fatalf("failed creating broadcast channel: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + primitive.RegisterUnmarshallers(channel) + + engineByMember := map[group.MemberIndex]*deterministicBuildTaggedTBTCSignerBootstrapRoundEngine{ + 1: &deterministicBuildTaggedTBTCSignerBootstrapRoundEngine{ + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 1, + Data: []byte{0x11, 0x01}, + }, + }, + }, + 2: &deterministicBuildTaggedTBTCSignerBootstrapRoundEngine{ + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 2, + Data: []byte{0x22, 0x02}, + }, + }, + }, + } + + requestByMember := map[group.MemberIndex]*NativeExecutionFFISigningRequest{ + 1: { + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 2, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + 2: { + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 2, + GroupSize: 2, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCtx() + + var wg sync.WaitGroup + signingErrors := make(chan error, len(requestByMember)) + + for memberIndex, request := range requestByMember { + engine := engineByMember[memberIndex] + wg.Add(1) + + go func( + signingRequest *NativeExecutionFFISigningRequest, + signingEngine NativeTBTCSignerEngine, + ) { + defer wg.Done() + + signingErrors <- executeBuildTaggedTBTCSignerBootstrapCoarseRound( + ctx, + signingRequest, + "group-1", + signingEngine, + nil, + nil, + ) + }(request, engine) + } + + wg.Wait() + close(signingErrors) + + for signingErr := range signingErrors { + if signingErr != nil { + t.Fatalf("unexpected signing error: [%v]", signingErr) + } + } + + for memberIndex, engine := range engineByMember { + finalizeInputs := engine.finalizeInputs() + if len(finalizeInputs) != 2 { + t.Fatalf( + "unexpected finalize input count for member [%v]\nexpected: [%v]\nactual: [%v]", + memberIndex, + 2, + len(finalizeInputs), + ) + } + + if finalizeInputs[0].Identifier != 1 || finalizeInputs[1].Identifier != 2 { + t.Fatalf( + "unexpected finalize identifiers for member [%v]\nexpected: [1 2]\nactual: [%v %v]", + memberIndex, + finalizeInputs[0].Identifier, + finalizeInputs[1].Identifier, + ) + } + + if len(finalizeInputs[0].Data) == 0 || len(finalizeInputs[1].Data) == 0 { + t.Fatalf("expected non-empty finalize contribution data for member [%v]", memberIndex) + } + + if !bytes.Equal(finalizeInputs[0].Data, []byte{0x11, 0x01}) { + t.Fatalf( + "unexpected contribution data for identifier 1, member [%v]\nexpected: [%x]\nactual: [%x]", + memberIndex, + []byte{0x11, 0x01}, + finalizeInputs[0].Data, + ) + } + + if !bytes.Equal(finalizeInputs[1].Data, []byte{0x22, 0x02}) { + t.Fatalf( + "unexpected contribution data for identifier 2, member [%v]\nexpected: [%x]\nactual: [%x]", + memberIndex, + []byte{0x22, 0x02}, + finalizeInputs[1].Data, + ) + } + } +} + +func TestExecuteBuildTaggedTBTCSignerBootstrapCoarseRound_UsesThresholdCohortOverFullGroup( + t *testing.T, +) { + provider := local.Connect() + channel, err := provider.BroadcastChannelFor("tbtc-signer-bootstrap-round-threshold-cohort-test") + if err != nil { + t.Fatalf("failed creating broadcast channel: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + primitive.RegisterUnmarshallers(channel) + + engineByMember := map[group.MemberIndex]*mockBuildTaggedTBTCSignerEngine{ + 1: { + startRoundState: &NativeTBTCSignerRoundState{ + SessionID: "session-threshold", + RoundID: "round-threshold", + RequiredContributions: 2, + MessageDigestHex: "0011", + SigningParticipants: []uint16{1, 3}, + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 1, + Data: []byte{0x11, 0x01}, + }, + }, + }, + 3: { + startRoundState: &NativeTBTCSignerRoundState{ + SessionID: "session-threshold", + RoundID: "round-threshold", + RequiredContributions: 2, + MessageDigestHex: "0011", + SigningParticipants: []uint16{1, 3}, + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 3, + Data: []byte{0x33, 0x03}, + }, + }, + }, + } + + requestByMember := map[group.MemberIndex]*NativeExecutionFFISigningRequest{ + 1: { + Message: big.NewInt(123), + SessionID: "session-threshold", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 3}, + }, + }, + 3: { + Message: big.NewInt(123), + SessionID: "session-threshold", + MemberIndex: 3, + GroupSize: 3, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 3}, + }, + }, + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCtx() + + var wg sync.WaitGroup + signingErrors := make(chan error, len(requestByMember)) + + for memberIndex, request := range requestByMember { + engine := engineByMember[memberIndex] + wg.Add(1) + + go func( + signingRequest *NativeExecutionFFISigningRequest, + signingEngine NativeTBTCSignerEngine, + ) { + defer wg.Done() + + signingErrors <- executeBuildTaggedTBTCSignerBootstrapCoarseRound( + ctx, + signingRequest, + "group-1", + signingEngine, + nil, + nil, + ) + }(request, engine) + } + + wg.Wait() + close(signingErrors) + + for signingErr := range signingErrors { + if signingErr != nil { + t.Fatalf("unexpected signing error: [%v]", signingErr) + } + } + + expectedSigningParticipants := []uint16{1, 3} + for memberIndex, engine := range engineByMember { + if !reflect.DeepEqual(engine.startSigningParticipants, expectedSigningParticipants) { + t.Fatalf( + "unexpected StartSignRound signing participants for member [%v]\nexpected: [%v]\nactual: [%v]", + memberIndex, + expectedSigningParticipants, + engine.startSigningParticipants, + ) + } + + if len(engine.finalizeInputs) != 2 { + t.Fatalf( + "unexpected finalize input count for member [%v]\nexpected: [%v]\nactual: [%v]", + memberIndex, + 2, + len(engine.finalizeInputs), + ) + } + + if engine.finalizeInputs[0].Identifier != 1 || engine.finalizeInputs[1].Identifier != 3 { + t.Fatalf( + "unexpected finalize identifiers for member [%v]\nexpected: [1 3]\nactual: [%v %v]", + memberIndex, + engine.finalizeInputs[0].Identifier, + engine.finalizeInputs[1].Identifier, + ) + } + } +} + +func TestExecuteBuildTaggedTBTCSignerBootstrapCoarseRound_FailsWhenRoundStateSigningParticipantsMismatch( + t *testing.T, +) { + request := &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 2, + DishonestThreshold: 1, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + } + + engine := &mockBuildTaggedTBTCSignerEngine{ + startRoundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + SigningParticipants: []uint16{1, 3}, + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 1, + Data: []byte{0x11, 0x01}, + }, + }, + } + + err := executeBuildTaggedTBTCSignerBootstrapCoarseRound( + context.Background(), + request, + "group-1", + engine, + nil, + nil, + ) + if err == nil { + t.Fatal("expected error") + } + + expectedErrFragment := "start sign round returned unexpected signing participant" + if !strings.Contains(err.Error(), expectedErrFragment) { + t.Fatalf( + "unexpected error\nexpected to contain: [%v]\nactual: [%v]", + expectedErrFragment, + err, + ) + } +} + +func TestExecuteBuildTaggedTBTCSignerBootstrapCoarseRound_FailsWhenRoundStateSigningParticipantsMissing( + t *testing.T, +) { + request := &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 2, + DishonestThreshold: 1, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + } + + engine := &mockBuildTaggedTBTCSignerEngine{ + startSignRoundFn: func( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + _ *[32]byte, + ) (*NativeTBTCSignerRoundState, error) { + return &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: memberIdentifier, + Data: []byte{0x11, 0x01}, + }, + }, nil + }, + } + + err := executeBuildTaggedTBTCSignerBootstrapCoarseRound( + context.Background(), + request, + "group-1", + engine, + nil, + nil, + ) + if err == nil { + t.Fatal("expected error") + } + + expectedErrFragment := "start sign round returned unexpected signing participants count" + if !strings.Contains(err.Error(), expectedErrFragment) { + t.Fatalf( + "unexpected error\nexpected to contain: [%v]\nactual: [%v]", + expectedErrFragment, + err, + ) + } +} + +func TestBuildTaggedTBTCSignerRoundKeyGroup(t *testing.T) { + testCases := []struct { + name string + payload *NativeTBTCSignerMaterialPayload + dkgResult *NativeTBTCSignerDKGResult + acceptScaffoldOptIn bool + expected string + substituted bool + expectError bool + expectScaffoldRefuse bool + }{ + { + name: "exact match", + payload: &NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + }, + dkgResult: &NativeTBTCSignerDKGResult{ + KeyGroup: "group-1", + }, + expected: "group-1", + substituted: false, + }, + { + name: "legacy source mismatch refused without opt-in", + payload: &NativeTBTCSignerMaterialPayload{ + KeyGroup: "legacy-group", + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + }, + dkgResult: &NativeTBTCSignerDKGResult{ + KeyGroup: "dkg-group", + }, + expectError: true, + expectScaffoldRefuse: true, + }, + { + name: "legacy source mismatch uses dkg key group with opt-in", + payload: &NativeTBTCSignerMaterialPayload{ + KeyGroup: "legacy-group", + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + }, + dkgResult: &NativeTBTCSignerDKGResult{ + KeyGroup: "dkg-group", + }, + acceptScaffoldOptIn: true, + expected: "dkg-group", + substituted: true, + }, + { + name: "non-legacy source mismatch rejects", + payload: &NativeTBTCSignerMaterialPayload{ + KeyGroup: "legacy-group", + KeyGroupSource: "dkg-persisted", + }, + dkgResult: &NativeTBTCSignerDKGResult{ + KeyGroup: "dkg-group", + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.acceptScaffoldOptIn { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + } else { + // Force the env to "" so a stray external value from a + // containing process cannot suppress the scaffold refusal + // during this test case. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "") + } + + actual, substituted, err := buildTaggedTBTCSignerRoundKeyGroup(tc.payload, tc.dkgResult) + if tc.expectError { + if err == nil { + t.Fatal("expected error") + } + + if tc.expectScaffoldRefuse && + !strings.Contains(err.Error(), AcceptScaffoldKeyGroupEnvVar) { + t.Fatalf( + "expected scaffold-refusal error referencing %s; got: [%v]", + AcceptScaffoldKeyGroupEnvVar, + err, + ) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actual != tc.expected { + t.Fatalf( + "unexpected key group\nexpected: [%v]\nactual: [%v]", + tc.expected, + actual, + ) + } + + if substituted != tc.substituted { + t.Fatalf( + "unexpected substitution flag\nexpected: [%v]\nactual: [%v]", + tc.substituted, + substituted, + ) + } + }) + } +} + +func TestIsBuildTaggedTBTCSignerBootstrapVersion(t *testing.T) { + testCases := []struct { + name string + version string + expected bool + }{ + { + name: "valid exact bootstrap", + version: "tbtc-signer/0.1.0-bootstrap", + expected: true, + }, + { + name: "valid bootstrap dotted suffix", + version: "tbtc-signer/0.1.0-bootstrap.1", + expected: true, + }, + { + name: "invalid non-bootstrap prerelease", + version: "tbtc-signer/0.1.0-post-bootstrap", + expected: false, + }, + { + name: "invalid major version one", + version: "tbtc-signer/1.0.0-bootstrap", + expected: false, + }, + { + name: "invalid missing prerelease", + version: "tbtc-signer/0.1.0", + expected: false, + }, + { + name: "invalid malformed core semver", + version: "tbtc-signer/0.1-bootstrap", + expected: false, + }, + { + name: "invalid prefix", + version: "other/0.1.0-bootstrap", + expected: false, + }, + { + name: "invalid uppercase bootstrap token", + version: "tbtc-signer/0.1.0-Bootstrap", + expected: false, + }, + { + name: "invalid substring trap", + version: "tbtc-signer/0.1.0-post-bootstrap-cleanup", + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := isBuildTaggedTBTCSignerBootstrapVersion(tc.version) + if actual != tc.expected { + t.Fatalf( + "unexpected bootstrap version classification\nversion: [%s]\nexpected: [%v]\nactual: [%v]", + tc.version, + tc.expected, + actual, + ) + } + }) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath( + t *testing.T, +) { + engine := &mockBuildTaggedTBTCSignerEngine{} + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1"}`), + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !engine.runDKGCalled { + t.Fatal("expected RunDKG call in tbtc-signer path") + } + + if engine.runDKGSessionID != "session-1" { + t.Fatalf( + "unexpected RunDKG session ID\nexpected: [%v]\nactual: [%v]", + "session-1", + engine.runDKGSessionID, + ) + } + + if engine.runDKGThreshold != 2 { + t.Fatalf( + "unexpected RunDKG threshold\nexpected: [%v]\nactual: [%v]", + 2, + engine.runDKGThreshold, + ) + } + + if len(engine.runDKGParticipants) != 3 { + t.Fatalf( + "unexpected RunDKG participants count\nexpected: [%v]\nactual: [%v]", + 3, + len(engine.runDKGParticipants), + ) + } + + if engine.startCalled { + t.Fatal("did not expect StartSignRound call for non-bootstrap tbtc-signer version") + } + + if engine.finalizeCalled { + t.Fatal("did not expect FinalizeSignRound call for non-bootstrap tbtc-signer version") + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion( + t *testing.T, +) { + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x11), + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + var observedCoarseSignatureEvents []NativeTBTCSignerCoarseSignatureEvent + err = RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + observedCoarseSignatureEvents = append( + observedCoarseSignatureEvents, + event, + ) + }, + ) + if err != nil { + t.Fatalf("unexpected coarse observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + signature, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1"}`), + }, + }) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if signature == nil { + t.Fatal("expected signature") + } + + marshaledSignature, err := signature.Marshal() + if err != nil { + t.Fatalf("cannot marshal signature: [%v]", err) + } + + expectedSignature := buildTaggedTBTCSignerValidTestSignature(0x11) + if !bytes.Equal(marshaledSignature, expectedSignature) { + t.Fatalf( + "unexpected signature bytes\nexpected: [%x]\nactual: [%x]", + expectedSignature, + marshaledSignature, + ) + } + + if !engine.runDKGCalled { + t.Fatal("expected RunDKG call in bootstrap tbtc-signer path") + } + + if !engine.startCalled { + t.Fatal("expected StartSignRound call in bootstrap tbtc-signer path") + } + + if engine.startSessionID != "session-1" { + t.Fatalf( + "unexpected StartSignRound session ID\nexpected: [%v]\nactual: [%v]", + "session-1", + engine.startSessionID, + ) + } + + if engine.startMemberID != 1 { + t.Fatalf( + "unexpected StartSignRound member identifier\nexpected: [%v]\nactual: [%v]", + 1, + engine.startMemberID, + ) + } + + if engine.startKeyGroup != "group-1" { + t.Fatalf( + "unexpected StartSignRound key group\nexpected: [%v]\nactual: [%v]", + "group-1", + engine.startKeyGroup, + ) + } + + expectedSigningParticipants := []uint16{1, 2, 3} + if !reflect.DeepEqual(engine.startSigningParticipants, expectedSigningParticipants) { + t.Fatalf( + "unexpected StartSignRound signing participants\nexpected: [%v]\nactual: [%v]", + expectedSigningParticipants, + engine.startSigningParticipants, + ) + } + + if !engine.finalizeCalled { + t.Fatal("expected FinalizeSignRound call in bootstrap tbtc-signer path") + } + + if len(observedEvents) != 0 { + t.Fatalf( + "did not expect fallback events\nactual: [%v]", + observedEvents, + ) + } + + if len(observedCoarseSignatureEvents) != 1 { + t.Fatalf( + "unexpected coarse signature event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedCoarseSignatureEvents), + ) + } + + if observedCoarseSignatureEvents[0].SessionID != "session-1" { + t.Fatalf( + "unexpected coarse signature session ID\nexpected: [%s]\nactual: [%s]", + "session-1", + observedCoarseSignatureEvents[0].SessionID, + ) + } + + if observedCoarseSignatureEvents[0].EngineVersion != "tbtc-signer/0.1.0-bootstrap" { + t.Fatalf( + "unexpected coarse signature engine version\nexpected: [%s]\nactual: [%s]", + "tbtc-signer/0.1.0-bootstrap", + observedCoarseSignatureEvents[0].EngineVersion, + ) + } + + if engine.finalizeSessionID != "session-1" { + t.Fatalf( + "unexpected FinalizeSignRound session ID\nexpected: [%v]\nactual: [%v]", + "session-1", + engine.finalizeSessionID, + ) + } + + if len(engine.finalizeInputs) != 3 { + t.Fatalf( + "unexpected FinalizeSignRound contributions count\nexpected: [%v]\nactual: [%v]", + 3, + len(engine.finalizeInputs), + ) + } + + expectedIdentifiers := []uint16{1, 2, 3} + for i, contribution := range engine.finalizeInputs { + if contribution.Identifier != expectedIdentifiers[i] { + t.Fatalf( + "unexpected contribution identifier at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedIdentifiers[i], + contribution.Identifier, + ) + } + + if len(contribution.Data) == 0 { + t.Fatalf("expected non-empty contribution data at index [%d]", i) + } + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_TaprootMerkleRoot( + t *testing.T, +) { + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x22), + } + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var taprootMerkleRoot [32]byte + taprootMerkleRoot[0] = 0xab + taprootMerkleRoot[31] = 0xcd + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + signature, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + TaprootMerkleRoot: &taprootMerkleRoot, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1"}`), + }, + }) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if signature == nil { + t.Fatal("expected signature") + } + + if engine.startTaprootMerkleRoot == nil { + t.Fatal("expected StartSignRound taproot merkle root") + } + if !bytes.Equal(engine.startTaprootMerkleRoot[:], taprootMerkleRoot[:]) { + t.Fatalf( + "unexpected StartSignRound taproot merkle root\nexpected: [%x]\nactual: [%x]", + taprootMerkleRoot, + *engine.startTaprootMerkleRoot, + ) + } + + if engine.finalizeTaprootMerkleRoot == nil { + t.Fatal("expected FinalizeSignRound taproot merkle root") + } + if !bytes.Equal(engine.finalizeTaprootMerkleRoot[:], taprootMerkleRoot[:]) { + t.Fatalf( + "unexpected FinalizeSignRound taproot merkle root\nexpected: [%x]\nactual: [%x]", + taprootMerkleRoot, + *engine.finalizeTaprootMerkleRoot, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_InvalidCoarseSignatureFallsBack( + t *testing.T, +) { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + finalizeSignature: []byte{0xaa}, + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + var observedCoarseSignatureEvents []NativeTBTCSignerCoarseSignatureEvent + err = RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + observedCoarseSignatureEvents = append( + observedCoarseSignatureEvents, + event, + ) + }, + ) + if err != nil { + t.Fatalf("unexpected coarse observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1","keyGroupSource":"legacy-wallet-pubkey"}`), + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !engine.runDKGCalled { + t.Fatal("expected RunDKG call in bootstrap path") + } + + if !engine.startCalled { + t.Fatal("expected StartSignRound call in bootstrap path") + } + + if !engine.finalizeCalled { + t.Fatal("expected FinalizeSignRound call in bootstrap path") + } + + if len(observedEvents) != 1 { + t.Fatalf( + "unexpected fallback event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedEvents), + ) + } + + if !strings.Contains( + observedEvents[0].Reason, + "cannot decode tbtc-signer coarse signature", + ) { + t.Fatalf( + "expected fallback reason to include decode failure\nactual: [%s]", + observedEvents[0].Reason, + ) + } + + if len(observedCoarseSignatureEvents) != 0 { + t.Fatalf( + "did not expect coarse signature events\nactual: [%v]", + observedCoarseSignatureEvents, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_RefusesScaffoldMaterialWithoutOptIn( + t *testing.T, +) { + // Closes the persistence-vs-execution gap: even when the native + // tbtc-signer engine is registered (scaffold material on disk from a + // previous opted-in session is enough to reach this point), the FFI + // signing path must refuse to feed RunDKG placeholder participant + // pubkeys without an active operator opt-in. Force the env var off so + // any value inherited from the test runner's containing process cannot + // suppress the refusal. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "") + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + runDKGResult: &NativeTBTCSignerDKGResult{ + SessionID: "session-scaffold-refused", + KeyGroup: "group-from-dkg", + ParticipantCount: 3, + Threshold: 2, + CreatedAtUnix: 1, + }, + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x22), + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + if err := RegisterNativeTBTCSignerEngine(engine); err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + signature, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-scaffold-refused", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte( + `{"keyGroup":"legacy-wallet-derived","keyGroupSource":"legacy-wallet-pubkey"}`, + ), + }, + }) + if err == nil { + t.Fatal("expected scaffold-refusal error from FFI signing path") + } + if signature != nil { + t.Fatal("expected nil signature when scaffold path is refused") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected ErrNativeCryptographyUnavailable wrap; got: [%v]", + err, + ) + } + if !strings.Contains(err.Error(), AcceptScaffoldKeyGroupEnvVar) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + AcceptScaffoldKeyGroupEnvVar, + err, + ) + } + if !strings.Contains(err.Error(), NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + err, + ) + } + + if engine.runDKGCalled { + t.Fatal( + "RunDKG must not be called when the scaffold opt-in flag is unset; " + + "refusing before the placeholder participant pubkeys are built " + + "is the whole point of the fence", + ) + } + if engine.startCalled { + t.Fatal("StartSignRound must not be called when the scaffold path is refused") + } + if engine.finalizeCalled { + t.Fatal( + "FinalizeSignRound must not be called when the scaffold path is refused", + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_LegacyKeyGroupSourceUsesRunDKGResult( + t *testing.T, +) { + // Scaffold-era path: legacy-wallet-pubkey signer material is refused by + // default; the operator opt-in via AcceptScaffoldKeyGroupEnvVar is what + // lets this test exercise the substitution. Production deployments must + // never set this. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + runDKGResult: &NativeTBTCSignerDKGResult{ + SessionID: "session-1", + KeyGroup: "group-from-dkg", + ParticipantCount: 3, + Threshold: 2, + CreatedAtUnix: 1, + }, + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x22), + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedCoarseSignatureEvents []NativeTBTCSignerCoarseSignatureEvent + err = RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + observedCoarseSignatureEvents = append( + observedCoarseSignatureEvents, + event, + ) + }, + ) + if err != nil { + t.Fatalf("unexpected coarse observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + signature, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte( + `{"keyGroup":"legacy-wallet-derived","keyGroupSource":"legacy-wallet-pubkey"}`, + ), + }, + }) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if signature == nil { + t.Fatal("expected signature") + } + + marshaledSignature, err := signature.Marshal() + if err != nil { + t.Fatalf("cannot marshal signature: [%v]", err) + } + + expectedSignature := buildTaggedTBTCSignerValidTestSignature(0x22) + if !bytes.Equal(marshaledSignature, expectedSignature) { + t.Fatalf( + "unexpected signature bytes\nexpected: [%x]\nactual: [%x]", + expectedSignature, + marshaledSignature, + ) + } + + if !engine.startCalled { + t.Fatal("expected StartSignRound call in bootstrap path") + } + + if engine.startKeyGroup != "group-from-dkg" { + t.Fatalf( + "unexpected StartSignRound key group\nexpected: [%v]\nactual: [%v]", + "group-from-dkg", + engine.startKeyGroup, + ) + } + + expectedSigningParticipants := []uint16{1, 2, 3} + if !reflect.DeepEqual(engine.startSigningParticipants, expectedSigningParticipants) { + t.Fatalf( + "unexpected StartSignRound signing participants\nexpected: [%v]\nactual: [%v]", + expectedSigningParticipants, + engine.startSigningParticipants, + ) + } + + if !engine.finalizeCalled { + t.Fatal("expected FinalizeSignRound call in bootstrap path") + } + + if len(observedCoarseSignatureEvents) != 1 { + t.Fatalf( + "unexpected coarse signature event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedCoarseSignatureEvents), + ) + } + + if observedCoarseSignatureEvents[0].KeyGroupSource != "legacy-wallet-pubkey" { + t.Fatalf( + "unexpected coarse signature key group source\nexpected: [%s]\nactual: [%s]", + "legacy-wallet-pubkey", + observedCoarseSignatureEvents[0].KeyGroupSource, + ) + } + + if observedCoarseSignatureEvents[0].EngineVersion != "tbtc-signer/0.1.0-bootstrap" { + t.Fatalf( + "unexpected coarse signature engine version\nexpected: [%s]\nactual: [%s]", + "tbtc-signer/0.1.0-bootstrap", + observedCoarseSignatureEvents[0].EngineVersion, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_KeyGroupMismatchNonLegacySourceSkipsCoarseRound( + t *testing.T, +) { + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + runDKGResult: &NativeTBTCSignerDKGResult{ + SessionID: "session-1", + KeyGroup: "group-from-dkg", + ParticipantCount: 3, + Threshold: 2, + CreatedAtUnix: 1, + }, + } + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte( + `{"keyGroup":"legacy-wallet-derived","keyGroupSource":"dkg-persisted"}`, + ), + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if engine.startCalled { + t.Fatal("did not expect StartSignRound call for non-legacy key-group mismatch") + } + + if engine.finalizeCalled { + t.Fatal("did not expect FinalizeSignRound call for non-legacy key-group mismatch") + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_NoEngineNoLegacyShare( + t *testing.T, +) { + // Scaffold-era signing path requires explicit operator opt-in; this test + // exercises the engine-unavailable + no-legacy-share branch which lives + // past the scaffold fence. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + var observedEvents []NativeTBTCSignerFallbackEvent + err := RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1","keyGroupSource":"legacy-wallet-pubkey"}`), + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if len(observedEvents) != 1 { + t.Fatalf( + "unexpected fallback event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedEvents), + ) + } + + event := observedEvents[0] + if event.SessionID != "session-1" { + t.Fatalf( + "unexpected fallback session ID\nexpected: [%s]\nactual: [%s]", + "session-1", + event.SessionID, + ) + } + + if event.KeyGroupSource != "legacy-wallet-pubkey" { + t.Fatalf( + "unexpected fallback key group source\nexpected: [%s]\nactual: [%s]", + "legacy-wallet-pubkey", + event.KeyGroupSource, + ) + } + + if event.LegacyPrivateKeyShareExists { + t.Fatal("expected fallback event without legacy private key share") + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_AttemptVariationRunDKGConflictFallsBack( + t *testing.T, +) { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + var firstParticipants []NativeTBTCSignerDKGParticipant + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0", + runDKGFn: func( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + ) (*NativeTBTCSignerDKGResult, error) { + if firstParticipants == nil { + firstParticipants = append( + []NativeTBTCSignerDKGParticipant{}, + participants..., + ) + + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil + } + + if !reflect.DeepEqual(participants, firstParticipants) { + return nil, errors.New("session_conflict") + } + + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil + }, + } + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + baseRequest := &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1","keyGroupSource":"legacy-wallet-pubkey"}`), + }, + } + + _, err = primitive.Sign(nil, nil, baseRequest) + if err == nil { + t.Fatal("expected first signing error due to legacy fallback without private key share") + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected first signing error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + secondRequest := *baseRequest + secondRequest.Attempt = &Attempt{ + Number: 2, + CoordinatorMemberIndex: 1, + ExcludedMembersIndexes: []group.MemberIndex{3}, + } + + _, err = primitive.Sign(nil, nil, &secondRequest) + if err == nil { + t.Fatal("expected second signing error due to legacy fallback without private key share") + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected second signing error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if len(observedEvents) != 2 { + t.Fatalf( + "unexpected fallback event count\nexpected: [%d]\nactual: [%d]", + 2, + len(observedEvents), + ) + } + + if !strings.Contains(observedEvents[1].Reason, "session_conflict") { + t.Fatalf( + "expected second fallback reason to include session_conflict\nactual: [%s]", + observedEvents[1].Reason, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_AttemptVariationStartSignRoundConflictFallsBack( + t *testing.T, +) { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + var firstSigningParticipants []uint16 + var observedSigningParticipants [][]uint16 + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x44), + runDKGFn: func( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + ) (*NativeTBTCSignerDKGResult, error) { + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil + }, + startSignRoundFn: func( + sessionID string, + _ uint16, + _ []byte, + _ string, + signingParticipants []uint16, + _ *[32]byte, + ) (*NativeTBTCSignerRoundState, error) { + observedSigningParticipants = append( + observedSigningParticipants, + append([]uint16{}, signingParticipants...), + ) + + if firstSigningParticipants == nil { + firstSigningParticipants = append( + []uint16{}, + signingParticipants..., + ) + } else if !reflect.DeepEqual(signingParticipants, firstSigningParticipants) { + return nil, errors.New("session_conflict") + } + + return &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "00", + SigningParticipants: append( + []uint16{}, + signingParticipants..., + ), + }, nil + }, + } + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + var observedCoarseSignatureEvents []NativeTBTCSignerCoarseSignatureEvent + err = RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + observedCoarseSignatureEvents = append( + observedCoarseSignatureEvents, + event, + ) + }, + ) + if err != nil { + t.Fatalf("unexpected coarse observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + baseRequest := &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1","keyGroupSource":"legacy-wallet-pubkey"}`), + }, + } + + firstSignature, err := primitive.Sign(nil, nil, baseRequest) + if err != nil { + t.Fatalf("unexpected first signing error: [%v]", err) + } + if firstSignature == nil { + t.Fatal("expected first signature") + } + + secondRequest := *baseRequest + secondRequest.Attempt = &Attempt{ + Number: 2, + CoordinatorMemberIndex: 1, + ExcludedMembersIndexes: []group.MemberIndex{2}, + } + + _, err = primitive.Sign(nil, nil, &secondRequest) + if err == nil { + t.Fatal("expected second signing error due to legacy fallback without private key share") + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected second signing error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if len(observedSigningParticipants) != 2 { + t.Fatalf( + "unexpected StartSignRound call count\nexpected: [%d]\nactual: [%d]", + 2, + len(observedSigningParticipants), + ) + } + + expectedFirstParticipants := []uint16{1, 2, 3} + if !reflect.DeepEqual(observedSigningParticipants[0], expectedFirstParticipants) { + t.Fatalf( + "unexpected first StartSignRound signing participants\nexpected: [%v]\nactual: [%v]", + expectedFirstParticipants, + observedSigningParticipants[0], + ) + } + + expectedSecondParticipants := []uint16{1, 3} + if !reflect.DeepEqual(observedSigningParticipants[1], expectedSecondParticipants) { + t.Fatalf( + "unexpected second StartSignRound signing participants\nexpected: [%v]\nactual: [%v]", + expectedSecondParticipants, + observedSigningParticipants[1], + ) + } + + if len(observedEvents) != 1 { + t.Fatalf( + "unexpected fallback event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedEvents), + ) + } + + if !strings.Contains(observedEvents[0].Reason, "session_conflict") { + t.Fatalf( + "expected fallback reason to include session_conflict\nactual: [%s]", + observedEvents[0].Reason, + ) + } + + if len(observedCoarseSignatureEvents) != 1 { + t.Fatalf( + "unexpected coarse signature event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedCoarseSignatureEvents), + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_InvalidAttemptPolicy_DoesNotFallback( + t *testing.T, +) { + // Scaffold-era signing path requires explicit operator opt-in; this test + // exercises the FFI flow's invalid-attempt-policy branch, which lives + // past the scaffold fence. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(3) + if err != nil { + t.Fatalf("failed loading key share fixtures: [%v]", err) + } + + privateKeyShare := tecdsa.NewPrivateKeyShare(fixtures[0]) + privateKeySharePayload, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling private key share: [%v]", err) + } + + signerMaterialPayload, err := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: hex.EncodeToString(privateKeySharePayload), + }) + if err != nil { + t.Fatalf("cannot marshal signer material payload: [%v]", err) + } + + testCases := []struct { + name string + attempt *Attempt + }{ + { + name: "zero attempt number", + attempt: &Attempt{ + Number: 0, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + { + name: "zero coordinator", + attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 0, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + { + name: "coordinator not included", + attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 3, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + { + name: "included members empty after exclusions", + attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + ExcludedMembersIndexes: []group.MemberIndex{1, 2, 3}, + }, + }, + { + name: "member included and excluded", + attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 2, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + ExcludedMembersIndexes: []group.MemberIndex{2}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: signerMaterialPayload, + }, + Attempt: tc.attempt, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if !errors.Is(err, ErrInvalidSigningAttemptPolicy) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrInvalidSigningAttemptPolicy, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected not to include: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if engine.runDKGCalled { + t.Fatal("did not expect RunDKG call for invalid attempt policy") + } + + if len(observedEvents) != 0 { + t.Fatalf( + "did not expect fallback events\nactual: [%v]", + observedEvents, + ) + } + }) + } +} + +func TestIsBuildTaggedTBTCSignerConsumedAttemptReplayError(t *testing.T) { + // Locking-mutex-free unit-coverage for the replay detector. Each case + // constructs an error in the shape that flows out of the FFI bridge today + // and asserts the detector's decision. + cases := []struct { + name string + err error + match bool + }{ + { + name: "nil error is not a replay", + err: nil, + match: false, + }, + { + name: "structured code wins over message wording", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: buildTaggedTBTCSignerConsumedAttemptReplayErrorCode, + Message: "rust message wording is not load-bearing here", + }, + ), + match: true, + }, + { + name: "structured but different code does not match", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "session_conflict", + Message: "attempt_id [x] already consumed for sign attempt in session [y]", + }, + ), + match: false, + }, + { + name: "legacy substring still matches when code is missing", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "", + Message: "attempt_id [att-1] already consumed for sign attempt in session [sess-1]", + }, + ), + match: true, + }, + { + // Pre-dedicated-variant signer builds route the replay path + // through Validation, so the code on the wire is + // validation_error and only the message identifies replay. + name: "validation_error code with legacy wording is a replay", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "validation_error", + Message: "attempt_id [att-1] already consumed for sign attempt in session [sess-1]", + }, + ), + match: true, + }, + { + // A validation_error that is NOT the replay path must not be + // flagged as a replay even if surrounding error chain noise + // happens to mention attempt_id elsewhere. + name: "validation_error without legacy wording is not a replay", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "validation_error", + Message: "session_id is empty", + }, + ), + match: false, + }, + { + name: "legacy substring still matches when error is a plain wrapper", + err: fmt.Errorf( + "native FROST bridge operation failed: tbtc-signer bridge " + + "operation [StartSignRound] failed: [validation_error: " + + "attempt_id [att-1] already consumed for sign attempt in " + + "session [sess-1]]", + ), + match: true, + }, + { + name: "unrelated error is not a replay", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "validation_error", + Message: "session_id is empty", + }, + ), + match: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isBuildTaggedTBTCSignerConsumedAttemptReplayError(tc.err); got != tc.match { + t.Fatalf( + "detector returned [%v]; expected [%v] for error [%v]", + got, + tc.match, + tc.err, + ) + } + }) + } +} + +func TestBuildTaggedTBTCSignerErrorPayload(t *testing.T) { + cases := []struct { + name string + payload []byte + code string + // Substring expected in the rendered Message. Empty means we don't + // assert beyond Code presence. + messageSubstring string + }{ + { + name: "decodes structured envelope", + payload: []byte(`{"code":"consumed_attempt_replay","message":"attempt_id [a] already consumed"}`), + code: "consumed_attempt_replay", + messageSubstring: "already consumed", + }, + { + name: "legacy validation_error code is preserved", + payload: []byte(`{"code":"validation_error","message":"session_id is empty"}`), + code: "validation_error", + messageSubstring: "session_id is empty", + }, + { + name: "message-only payload leaves Code empty", + payload: []byte(`{"message":"opaque message"}`), + code: "", + messageSubstring: "opaque message", + }, + { + name: "completely empty envelope surfaces the raw payload", + payload: []byte(`{}`), + code: "", + messageSubstring: "empty error payload", + }, + { + name: "non-JSON payload is reported as a decode failure", + payload: []byte(`not json`), + code: "", + messageSubstring: "cannot decode error payload", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + structured := buildTaggedTBTCSignerErrorPayload(tc.payload) + if structured == nil { + t.Fatal("expected non-nil structured error") + } + if structured.Code != tc.code { + t.Fatalf( + "unexpected Code\nexpected: [%s]\nactual: [%s]", + tc.code, + structured.Code, + ) + } + if tc.messageSubstring != "" && + !strings.Contains(structured.Message, tc.messageSubstring) { + t.Fatalf( + "Message missing expected substring\nexpected substring: [%s]\nactual: [%s]", + tc.messageSubstring, + structured.Message, + ) + } + }) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_ABIIncompatible_DoesNotFallback( + t *testing.T, +) { + // Codex #4105 P2: a PRESENT-but-incompatible engine lib must fail CLOSED. The coarse + // path must NOT fall back to legacy tECDSA signing even though the material carries a + // legacy private key share (the ABI error was previously swallowed by the fallback, + // signing anyway). A valid attempt is used so the only thing stopping a signature is + // the ABI guard. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + // Force the engine-lib ABI preflight to report a present-but-incompatible lib. + originalCheck := tbtcSignerABIIncompatibilityCheck + tbtcSignerABIIncompatibilityCheck = func() error { return ErrTBTCSignerABIIncompatible } + t.Cleanup(func() { tbtcSignerABIIncompatibilityCheck = originalCheck }) + + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(3) + if err != nil { + t.Fatalf("failed loading key share fixtures: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(fixtures[0]) + privateKeySharePayload, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling private key share: [%v]", err) + } + signerMaterialPayload, err := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: hex.EncodeToString(privateKeySharePayload), + }) + if err != nil { + t.Fatalf("cannot marshal signer material payload: [%v]", err) + } + + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + if err := RegisterNativeTBTCSignerEngine( + &mockBuildTaggedTBTCSignerEngine{version: "tbtc-signer/0.1.0-bootstrap"}, + ); err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + if err := RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ); err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + signature, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: signerMaterialPayload, + }, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }) + + if signature != nil { + t.Fatal("an incompatible engine lib must not produce a signature (no legacy fallback)") + } + if !errors.Is(err, ErrTBTCSignerABIIncompatible) { + t.Fatalf("expected ErrTBTCSignerABIIncompatible, got: [%v]", err) + } + if len(observedEvents) != 0 { + t.Fatalf( + "legacy fallback must NOT be reached on ABI incompatibility; got %d fallback event(s)", + len(observedEvents), + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_ConsumedAttemptReplay_DoesNotFallback( + t *testing.T, +) { + // Scaffold-era signing path requires explicit operator opt-in; this test + // exercises the FFI flow's consumed-attempt-replay branch, which lives + // past the scaffold fence. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(3) + if err != nil { + t.Fatalf("failed loading key share fixtures: [%v]", err) + } + + privateKeyShare := tecdsa.NewPrivateKeyShare(fixtures[0]) + privateKeySharePayload, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling private key share: [%v]", err) + } + + signerMaterialPayload, err := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: hex.EncodeToString(privateKeySharePayload), + }) + if err != nil { + t.Fatalf("cannot marshal signer material payload: [%v]", err) + } + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + startErr: errors.New( + "validation: attempt_id [11] already consumed for sign attempt in session [session-1]", + ), + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + err = RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: signerMaterialPayload, + }, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if !errors.Is(err, ErrConsumedSigningAttemptReplay) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrConsumedSigningAttemptReplay, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected not to include: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !engine.runDKGCalled { + t.Fatal("expected RunDKG call before consumed-attempt replay rejection") + } + + if len(observedEvents) != 0 { + t.Fatalf( + "did not expect fallback events\nactual: [%v]", + observedEvents, + ) + } + + if !strings.Contains(err.Error(), "already consumed for sign attempt") { + t.Fatalf( + "expected replay fragment in error message\nactual: [%v]", + err, + ) + } +} diff --git a/pkg/frost/signing/native_frost_dkg_engine_frost_native.go b/pkg/frost/signing/native_frost_dkg_engine_frost_native.go new file mode 100644 index 0000000000..c3cca7ade5 --- /dev/null +++ b/pkg/frost/signing/native_frost_dkg_engine_frost_native.go @@ -0,0 +1,54 @@ +//go:build frost_native + +package signing + +// NativeFROSTDKGRound1Package is the public package broadcast during FROST DKG +// round one. +type NativeFROSTDKGRound1Package struct { + Identifier string `json:"identifier"` + Data []byte `json:"data"` +} + +// NativeFROSTDKGRound2Package is the package sent to a specific DKG +// participant during FROST DKG round two. +type NativeFROSTDKGRound2Package struct { + // Identifier is the recipient participant identifier embedded by the + // native DKG package. + Identifier string `json:"identifier"` + // SenderIdentifier is filled by the Go coordinator for packages received + // from peers. The tbtc-signer DKG Part3 request keys round-two packages by + // sender while the package itself carries the recipient. + SenderIdentifier string `json:"senderIdentifier,omitempty"` + Data []byte `json:"data"` +} + +// NativeFROSTDKGRound1SecretPackage is signer-local secret material produced +// in DKG round one. It must never be broadcast. +type NativeFROSTDKGRound1SecretPackage struct { + Data []byte `json:"data"` +} + +// NativeFROSTDKGRound2SecretPackage is signer-local secret material produced +// in DKG round two. It must never be broadcast. +type NativeFROSTDKGRound2SecretPackage struct { + Data []byte `json:"data"` +} + +// NativeFROSTDKGPart1Result is the output of native FROST DKG part one. +type NativeFROSTDKGPart1Result struct { + SecretPackage *NativeFROSTDKGRound1SecretPackage `json:"secretPackage"` + Package *NativeFROSTDKGRound1Package `json:"package"` +} + +// NativeFROSTDKGPart2Result is the output of native FROST DKG part two. +type NativeFROSTDKGPart2Result struct { + SecretPackage *NativeFROSTDKGRound2SecretPackage `json:"secretPackage"` + Packages []*NativeFROSTDKGRound2Package `json:"packages"` +} + +// NativeFROSTDKGResult is the final native FROST DKG output consumed by the +// signing runtime and persisted by keep-core. +type NativeFROSTDKGResult struct { + KeyPackage *NativeFROSTKeyPackage `json:"keyPackage"` + PublicKeyPackage *NativeFROSTPublicKeyPackage `json:"publicKeyPackage"` +} diff --git a/pkg/frost/signing/native_frost_engine_frost_native.go b/pkg/frost/signing/native_frost_engine_frost_native.go new file mode 100644 index 0000000000..45e6a9855a --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_frost_native.go @@ -0,0 +1,63 @@ +//go:build frost_native + +package signing + +const ( + // NativeSignerMaterialFormatFrostUniFFIV2 is the unsupported generic UniFFI + // FROST signer-material envelope. It is kept as a string constant so stale + // local/test material can be identified and rejected explicitly. + NativeSignerMaterialFormatFrostUniFFIV2 = "frost-uniffi-v2" +) + +// NativeFROSTKeyPackage carries native key-package bytes and participant +// identifier expected by the native FROST engine. +type NativeFROSTKeyPackage struct { + Identifier string `json:"identifier"` + Data []byte `json:"data"` +} + +// NativeFROSTPublicKeyPackage carries native public-key-package payload. +type NativeFROSTPublicKeyPackage struct { + VerifyingShares map[string]string `json:"verifyingShares"` + VerifyingKey string `json:"verifyingKey"` +} + +// NativeFROSTNonces is round-one signer-local nonce material. FROST signing +// nonces are one-time secrets. The generic UniFFI signing protocol that used +// this type is no longer registered; it remains only as a tbtc-signer FFI DTO. +type NativeFROSTNonces struct { + Data []byte `json:"data"` +} + +// NativeFROSTCommitment is round-one commitment shared with the group. +type NativeFROSTCommitment struct { + Identifier string `json:"identifier"` + Data []byte `json:"data"` +} + +// NativeFROSTSigningPackage is coordinator-computed package used in round two. +type NativeFROSTSigningPackage struct { + Data []byte `json:"data"` +} + +// NativeFROSTSignatureShare is round-two signature share. +type NativeFROSTSignatureShare struct { + Identifier string `json:"identifier"` + Data []byte `json:"data"` +} + +type nativeFROSTCommitment struct { + Identifier string + Data []byte +} + +type nativeFROSTSignatureShare struct { + Identifier string + Data []byte +} + +func zeroBytes(data []byte) { + for i := range data { + data[i] = 0 + } +} diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go new file mode 100644 index 0000000000..5e80a660a1 --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go @@ -0,0 +1,3698 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +/* +#cgo CFLAGS: -std=c11 +#cgo linux LDFLAGS: -ldl +#cgo freebsd LDFLAGS: -ldl +#include +#include +#include +#include + +typedef struct { + uint8_t* ptr; + size_t len; +} TbtcBuffer; + +typedef struct { + int32_t status_code; + TbtcBuffer buffer; +} TbtcSignerResult; + +typedef TbtcSignerResult (*tbtc_version_fn)(void); +typedef TbtcSignerResult (*tbtc_abi_version_fn)(void); +typedef TbtcSignerResult (*tbtc_run_dkg_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_dkg_part1_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_dkg_part2_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_dkg_part3_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_persist_distributed_dkg_key_package_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_generate_nonces_and_commitments_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_new_signing_package_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_sign_share_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_aggregate_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_verify_signature_share_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_derive_interactive_attempt_context_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_interactive_session_open_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_interactive_round1_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_interactive_round2_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_interactive_session_abort_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_interactive_aggregate_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_start_sign_round_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_finalize_sign_round_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_build_taproot_tx_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_init_signer_config_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef void (*tbtc_free_buffer_fn)(uint8_t* ptr, size_t len); + +static TbtcSignerResult unavailable_tbtc_signer_result(void) { + TbtcSignerResult result; + result.status_code = -1; + result.buffer.ptr = NULL; + result.buffer.len = 0; + return result; +} + +static TbtcSignerResult tbtc_signer_version(void) { + tbtc_version_fn version = (tbtc_version_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_version" + ); + if (version == NULL) { + return unavailable_tbtc_signer_result(); + } + + return version(); +} + +static TbtcSignerResult tbtc_signer_abi_version(void) { + tbtc_abi_version_fn abi_version = (tbtc_abi_version_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_abi_version" + ); + if (abi_version == NULL) { + return unavailable_tbtc_signer_result(); + } + + return abi_version(); +} + +static TbtcSignerResult tbtc_signer_run_dkg(const uint8_t* request_ptr, size_t request_len) { + tbtc_run_dkg_fn run_dkg = (tbtc_run_dkg_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_run_dkg" + ); + if (run_dkg == NULL) { + return unavailable_tbtc_signer_result(); + } + + return run_dkg(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_dkg_part1(const uint8_t* request_ptr, size_t request_len) { + tbtc_dkg_part1_fn dkg_part1 = (tbtc_dkg_part1_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_dkg_part1" + ); + if (dkg_part1 == NULL) { + return unavailable_tbtc_signer_result(); + } + + return dkg_part1(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_dkg_part2(const uint8_t* request_ptr, size_t request_len) { + tbtc_dkg_part2_fn dkg_part2 = (tbtc_dkg_part2_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_dkg_part2" + ); + if (dkg_part2 == NULL) { + return unavailable_tbtc_signer_result(); + } + + return dkg_part2(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_dkg_part3(const uint8_t* request_ptr, size_t request_len) { + tbtc_dkg_part3_fn dkg_part3 = (tbtc_dkg_part3_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_dkg_part3" + ); + if (dkg_part3 == NULL) { + return unavailable_tbtc_signer_result(); + } + + return dkg_part3(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_persist_distributed_dkg_key_package(const uint8_t* request_ptr, size_t request_len) { + tbtc_persist_distributed_dkg_key_package_fn persist = + (tbtc_persist_distributed_dkg_key_package_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_persist_distributed_dkg_key_package" + ); + if (persist == NULL) { + return unavailable_tbtc_signer_result(); + } + + return persist(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_generate_nonces_and_commitments(const uint8_t* request_ptr, size_t request_len) { + tbtc_generate_nonces_and_commitments_fn generate_nonces_and_commitments = + (tbtc_generate_nonces_and_commitments_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_generate_nonces_and_commitments" + ); + if (generate_nonces_and_commitments == NULL) { + return unavailable_tbtc_signer_result(); + } + + return generate_nonces_and_commitments(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_new_signing_package(const uint8_t* request_ptr, size_t request_len) { + tbtc_new_signing_package_fn new_signing_package = (tbtc_new_signing_package_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_new_signing_package" + ); + if (new_signing_package == NULL) { + return unavailable_tbtc_signer_result(); + } + + return new_signing_package(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_sign_share(const uint8_t* request_ptr, size_t request_len) { + tbtc_sign_share_fn sign_share = (tbtc_sign_share_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_sign_share" + ); + if (sign_share == NULL) { + return unavailable_tbtc_signer_result(); + } + + return sign_share(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_aggregate(const uint8_t* request_ptr, size_t request_len) { + tbtc_aggregate_fn aggregate = (tbtc_aggregate_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_aggregate" + ); + if (aggregate == NULL) { + return unavailable_tbtc_signer_result(); + } + + return aggregate(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_verify_signature_share(const uint8_t* request_ptr, size_t request_len) { + tbtc_verify_signature_share_fn verify_signature_share = (tbtc_verify_signature_share_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_verify_signature_share" + ); + if (verify_signature_share == NULL) { + return unavailable_tbtc_signer_result(); + } + + return verify_signature_share(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_derive_interactive_attempt_context(const uint8_t* request_ptr, size_t request_len) { + tbtc_derive_interactive_attempt_context_fn derive_interactive_attempt_context = (tbtc_derive_interactive_attempt_context_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_derive_interactive_attempt_context" + ); + if (derive_interactive_attempt_context == NULL) { + return unavailable_tbtc_signer_result(); + } + + return derive_interactive_attempt_context(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_interactive_session_open(const uint8_t* request_ptr, size_t request_len) { + tbtc_interactive_session_open_fn interactive_session_open = (tbtc_interactive_session_open_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_interactive_session_open" + ); + if (interactive_session_open == NULL) { + return unavailable_tbtc_signer_result(); + } + + return interactive_session_open(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_interactive_round1(const uint8_t* request_ptr, size_t request_len) { + tbtc_interactive_round1_fn interactive_round1 = (tbtc_interactive_round1_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_interactive_round1" + ); + if (interactive_round1 == NULL) { + return unavailable_tbtc_signer_result(); + } + + return interactive_round1(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_interactive_round2(const uint8_t* request_ptr, size_t request_len) { + tbtc_interactive_round2_fn interactive_round2 = (tbtc_interactive_round2_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_interactive_round2" + ); + if (interactive_round2 == NULL) { + return unavailable_tbtc_signer_result(); + } + + return interactive_round2(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_interactive_session_abort(const uint8_t* request_ptr, size_t request_len) { + tbtc_interactive_session_abort_fn interactive_session_abort = (tbtc_interactive_session_abort_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_interactive_session_abort" + ); + if (interactive_session_abort == NULL) { + return unavailable_tbtc_signer_result(); + } + + return interactive_session_abort(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_interactive_aggregate(const uint8_t* request_ptr, size_t request_len) { + tbtc_interactive_aggregate_fn interactive_aggregate = (tbtc_interactive_aggregate_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_interactive_aggregate" + ); + if (interactive_aggregate == NULL) { + return unavailable_tbtc_signer_result(); + } + + return interactive_aggregate(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_start_sign_round(const uint8_t* request_ptr, size_t request_len) { + tbtc_start_sign_round_fn start_sign_round = (tbtc_start_sign_round_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_start_sign_round" + ); + if (start_sign_round == NULL) { + return unavailable_tbtc_signer_result(); + } + + return start_sign_round(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_finalize_sign_round(const uint8_t* request_ptr, size_t request_len) { + tbtc_finalize_sign_round_fn finalize_sign_round = (tbtc_finalize_sign_round_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_finalize_sign_round" + ); + if (finalize_sign_round == NULL) { + return unavailable_tbtc_signer_result(); + } + + return finalize_sign_round(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_build_taproot_tx(const uint8_t* request_ptr, size_t request_len) { + tbtc_build_taproot_tx_fn build_taproot_tx = (tbtc_build_taproot_tx_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_build_taproot_tx" + ); + if (build_taproot_tx == NULL) { + return unavailable_tbtc_signer_result(); + } + + return build_taproot_tx(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_init_signer_config(const uint8_t* request_ptr, size_t request_len) { + tbtc_init_signer_config_fn init_signer_config = (tbtc_init_signer_config_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_init_signer_config" + ); + if (init_signer_config == NULL) { + return unavailable_tbtc_signer_result(); + } + + return init_signer_config(request_ptr, request_len); +} + +static void tbtc_signer_free_buffer(uint8_t* ptr, size_t len) { + tbtc_free_buffer_fn free_buffer = (tbtc_free_buffer_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_free_buffer" + ); + if (free_buffer != NULL) { + free_buffer(ptr, len); + } +} +*/ +import "C" + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "math" + "unsafe" +) + +type buildTaggedTBTCSignerEngine struct{} + +// The cgo-backed engine must satisfy the runner's interactiveSigningEngine +// boundary (defined under the frost_native tag, which this file also carries). +// This assertion lives in the cgo wiring layer so widening the interface there +// is compile-checked against the real engine, even before a production path +// constructs one. +var _ interactiveSigningEngine = (*buildTaggedTBTCSignerEngine)(nil) + +// The cgo engine must also satisfy the share-blame re-verifier boundary +// (Round2ShareVerifyingEngine, RFC-21 Phase 7.3 share-blame): the drive type-asserts +// the registered engine to it to classify interactive aggregate share-verification +// culprits. Compile-check it here against the real engine. +var _ Round2ShareVerifyingEngine = (*buildTaggedTBTCSignerEngine)(nil) + +type buildTaggedTBTCSignerRunDKGRequest struct { + SessionID string `json:"session_id"` + Participants []buildTaggedTBTCSignerDKGParticipant `json:"participants"` + Threshold uint16 `json:"threshold"` + DKGSeedHex *string `json:"dkg_seed_hex,omitempty"` +} + +type buildTaggedTBTCSignerDKGParticipant struct { + Identifier uint16 `json:"identifier"` + PublicKeyHex string `json:"public_key_hex"` +} + +type buildTaggedTBTCSignerRunDKGResponse struct { + SessionID string `json:"session_id"` + KeyGroup string `json:"key_group"` + ParticipantCount uint16 `json:"participant_count"` + Threshold uint16 `json:"threshold"` + CreatedAtUnix uint64 `json:"created_at_unix"` +} + +type buildTaggedTBTCSignerDKGPart1Request struct { + ParticipantIdentifier string `json:"participant_identifier"` + MaxSigners uint16 `json:"max_signers"` + MinSigners uint16 `json:"min_signers"` +} + +type buildTaggedTBTCSignerDKGRound1Package struct { + Identifier string `json:"identifier"` + PackageHex string `json:"package_hex"` +} + +type buildTaggedTBTCSignerDKGRound2Package struct { + Identifier string `json:"identifier"` + SenderIdentifier *string `json:"sender_identifier,omitempty"` + PackageHex string `json:"package_hex"` +} + +type buildTaggedTBTCSignerDKGPart1Response struct { + SecretPackageHex string `json:"secret_package_hex"` + Package *buildTaggedTBTCSignerDKGRound1Package `json:"package"` +} + +type buildTaggedTBTCSignerDKGPart2Request struct { + SecretPackageHex string `json:"secret_package_hex"` + Round1Packages []buildTaggedTBTCSignerDKGRound1Package `json:"round1_packages"` +} + +type buildTaggedTBTCSignerDKGPart2Response struct { + SecretPackageHex string `json:"secret_package_hex"` + Packages []buildTaggedTBTCSignerDKGRound2Package `json:"packages"` +} + +type buildTaggedTBTCSignerDKGPart3Request struct { + SecretPackageHex string `json:"secret_package_hex"` + Round1Packages []buildTaggedTBTCSignerDKGRound1Package `json:"round1_packages"` + Round2Packages []buildTaggedTBTCSignerDKGRound2Package `json:"round2_packages"` +} + +type buildTaggedTBTCSignerNativeFROSTKeyPackage struct { + Identifier string `json:"identifier"` + DataHex string `json:"data_hex"` +} + +type buildTaggedTBTCSignerNativeFROSTPublicKeyPackage struct { + VerifyingShares map[string]string `json:"verifying_shares"` + VerifyingKey string `json:"verifying_key"` +} + +type buildTaggedTBTCSignerDKGPart3Response struct { + KeyPackage *buildTaggedTBTCSignerNativeFROSTKeyPackage `json:"key_package"` + PublicKeyPackage *buildTaggedTBTCSignerNativeFROSTPublicKeyPackage `json:"public_key_package"` +} + +type buildTaggedTBTCSignerPersistDistributedDKGKeyPackageRequest struct { + SessionID string `json:"session_id"` + ParticipantIdentifier uint16 `json:"participant_identifier"` + Threshold uint16 `json:"threshold"` + ParticipantCount uint16 `json:"participant_count"` + KeyPackage *buildTaggedTBTCSignerNativeFROSTKeyPackage `json:"key_package"` + PublicKeyPackage *buildTaggedTBTCSignerNativeFROSTPublicKeyPackage `json:"public_key_package"` +} + +type buildTaggedTBTCSignerNativeFROSTCommitment struct { + Identifier string `json:"identifier"` + DataHex string `json:"data_hex"` +} + +type buildTaggedTBTCSignerNativeFROSTSignatureShare struct { + Identifier string `json:"identifier"` + DataHex string `json:"data_hex"` +} + +type buildTaggedTBTCSignerGenerateNoncesRequest struct { + KeyPackageIdentifier string `json:"key_package_identifier"` + KeyPackageHex string `json:"key_package_hex"` +} + +type buildTaggedTBTCSignerGenerateNoncesResponse struct { + NoncesHex string `json:"nonces_hex"` + Commitment *buildTaggedTBTCSignerNativeFROSTCommitment `json:"commitment"` +} + +type buildTaggedTBTCSignerNewSigningPackageRequest struct { + MessageHex string `json:"message_hex"` + Commitments []buildTaggedTBTCSignerNativeFROSTCommitment `json:"commitments"` +} + +type buildTaggedTBTCSignerNewSigningPackageResponse struct { + SigningPackageHex string `json:"signing_package_hex"` +} + +type buildTaggedTBTCSignerSignShareRequest struct { + SigningPackageHex string `json:"signing_package_hex"` + NoncesHex string `json:"nonces_hex"` + KeyPackageIdentifier string `json:"key_package_identifier"` + KeyPackageHex string `json:"key_package_hex"` +} + +type buildTaggedTBTCSignerSignShareResponse struct { + SignatureShare *buildTaggedTBTCSignerNativeFROSTSignatureShare `json:"signature_share"` +} + +type buildTaggedTBTCSignerAggregateRequest struct { + SigningPackageHex string `json:"signing_package_hex"` + SignatureShares []buildTaggedTBTCSignerNativeFROSTSignatureShare `json:"signature_shares"` + PublicKeyPackage *buildTaggedTBTCSignerNativeFROSTPublicKeyPackage `json:"public_key_package"` +} + +type buildTaggedTBTCSignerAggregateResponse struct { + SignatureHex string `json:"signature_hex"` +} + +type buildTaggedTBTCSignerVerifySignatureShareRequest struct { + SessionID string `json:"session_id"` + SigningPackageHex string `json:"signing_package_hex"` + SignatureShareHex string `json:"signature_share_hex"` + MemberIdentifier uint16 `json:"member_identifier"` + TaprootMerkleRootHex *string `json:"taproot_merkle_root_hex,omitempty"` +} + +type buildTaggedTBTCSignerVerifySignatureShareResponse struct { + Verdict string `json:"verdict"` +} + +type buildTaggedTBTCSignerStartSignRoundRequest struct { + SessionID string `json:"session_id"` + MemberIdentifier uint16 `json:"member_identifier"` + MessageHex string `json:"message_hex"` + KeyGroup string `json:"key_group"` + TaprootMerkleRootHex *string `json:"taproot_merkle_root_hex,omitempty"` + SigningParticipants []uint16 `json:"signing_participants,omitempty"` +} + +type buildTaggedTBTCSignerStartSignRoundResponse struct { + SessionID string `json:"session_id"` + RoundID string `json:"round_id"` + RequiredContributions uint16 `json:"required_contributions"` + MessageDigestHex string `json:"message_digest_hex"` + SigningParticipants []uint16 `json:"signing_participants,omitempty"` + OwnContribution *buildTaggedTBTCSignerFinalizeRoundContribution `json:"own_contribution"` +} + +type buildTaggedTBTCSignerFinalizeSignRoundRequest struct { + SessionID string `json:"session_id"` + TaprootMerkleRootHex *string `json:"taproot_merkle_root_hex,omitempty"` + RoundContributions []buildTaggedTBTCSignerFinalizeRoundContribution `json:"round_contributions"` +} + +type buildTaggedTBTCSignerFinalizeRoundContribution struct { + Identifier uint16 `json:"identifier"` + SignatureShareHex string `json:"signature_share_hex"` +} + +type buildTaggedTBTCSignerFinalizeSignRoundResponse struct { + SessionID string `json:"session_id"` + RoundID string `json:"round_id"` + SignatureHex string `json:"signature_hex"` +} + +type buildTaggedTBTCSignerBuildTaprootTxRequest struct { + SessionID string `json:"session_id"` + Inputs []buildTaggedTBTCSignerBuildTaprootTxInput `json:"inputs"` + Outputs []buildTaggedTBTCSignerBuildTaprootTxOutput `json:"outputs"` + ScriptTreeHex *string `json:"script_tree_hex,omitempty"` +} + +type buildTaggedTBTCSignerBuildTaprootTxInput struct { + TxIDHex string `json:"txid_hex"` + Vout uint32 `json:"vout"` + ValueSats uint64 `json:"value_sats"` +} + +type buildTaggedTBTCSignerBuildTaprootTxOutput struct { + ScriptPubKeyHex string `json:"script_pubkey_hex"` + ValueSats uint64 `json:"value_sats"` +} + +type buildTaggedTBTCSignerBuildTaprootTxResponse struct { + SessionID string `json:"session_id"` + TxHex string `json:"tx_hex"` +} + +const buildTaggedTBTCSignerUnavailableStatusCode = -1 + +func registerBuildTaggedNativeFROSTSigningEngine() error { + engine := &buildTaggedTBTCSignerEngine{} + + // Do not register the tbtc-signer bridge as the generic UniFFI-shaped + // FROST DKG/signing engine. That path persists `frost-uniffi-v2` wallet + // material, which cannot produce Taproot-tweaked signatures. A wallet + // using that material can accept Taproot deposits that are effectively + // unsweepable, so this must fail before new FROST wallet material exists. + // New FROST wallets in this build must use the coarse + // `frost-tbtc-signer-v1` material path exclusively. + // + // RFC-21 Phase 7.3: this same engine satisfies interactiveSigningEngine, and + // it IS registered as the interactive provider here. The prerequisites the + // registration waited on have landed -- the f+1 blame/evidence bridge and the + // stable ROAST session-key plumbing -- so the executor may drive the real cgo + // engine through the interactive ROAST path. Registration on its own changes + // nothing for an operator: the executor still requires the default-off + // KEEP_CORE_FROST_INTERACTIVE_SIGNING_ENABLED opt-in (read per call, see + // roast_interactive_signing_gate.go), so the interactive path stays dormant + // until explicitly enabled on a cgo build, and the coarse path remains the + // fallback. The frost-secp256k1-tr engine external audit gates the + // threshold-ECDSA -> FROST CUTOVER in production (turning that opt-in on for + // real wallets), NOT this registration. The provider is a factory: each call + // returns a fresh stateless bridge handle (interactive sessions live + // engine-side, keyed by session id). + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { + return &buildTaggedTBTCSignerEngine{} + }) + + return RegisterNativeTBTCSignerEngine(engine) +} + +func (bttse *buildTaggedTBTCSignerEngine) Version() (string, error) { + responsePayload, err := callBuildTaggedTBTCSignerVersion() + if err != nil { + return "", err + } + + version := string(responsePayload) + if version == "" { + return "", buildTaggedTBTCSignerOperationError( + "Version", + "response version is empty", + ) + } + + return version, nil +} + +func (bttse *buildTaggedTBTCSignerEngine) RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*NativeTBTCSignerDKGResult, error) { + requestPayload, err := buildTaggedTBTCSignerRunDKGRequestPayload( + sessionID, + participants, + threshold, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerRunDKG(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerRunDKGResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) RunDKGWithSeed( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + dkgSeedHex string, +) (*NativeTBTCSignerDKGResult, error) { + requestPayload, err := buildTaggedTBTCSignerRunDKGRequestPayloadWithSeed( + sessionID, + participants, + threshold, + dkgSeedHex, + ) + if err != nil { + return nil, err + } + // The request embeds the DKG seed, which deterministically drives key + // generation and therefore reconstructs the group secret; scrub the + // Go-side buffer on every return path, mirroring the Sign path. The C copy + // is separately scrubbed in callBuildTaggedTBTCSignerOperation. The RunDKG + // response carries only public metadata, so it is not zeroized. + defer zeroBytes(requestPayload) + + responsePayload, err := callBuildTaggedTBTCSignerRunDKG(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerRunDKGResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) Part1( + participantIdentifier string, + maxSigners uint16, + minSigners uint16, +) (*NativeFROSTDKGPart1Result, error) { + requestPayload, err := buildTaggedTBTCSignerDKGPart1RequestPayload( + participantIdentifier, + maxSigners, + minSigners, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerDKGPart1(requestPayload) + if err != nil { + return nil, err + } + // The response carries the round-1 secret package (private polynomial + // coefficients that must never be broadcast). Scrub the Go-side transport + // buffer once decoded, mirroring the Sign path's zeroBytes hygiene; the + // decoded secret returned to the caller is a fresh, independent copy. + defer zeroBytes(responsePayload) + + return decodeBuildTaggedTBTCSignerDKGPart1Response(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) Part2( + secretPackage *NativeFROSTDKGRound1SecretPackage, + round1Packages []*NativeFROSTDKGRound1Package, +) (*NativeFROSTDKGPart2Result, error) { + requestPayload, err := buildTaggedTBTCSignerDKGPart2RequestPayload( + secretPackage, + round1Packages, + ) + if err != nil { + return nil, err + } + // The request embeds the round-1 secret package; scrub the Go-side buffer + // on every return path (including a failed FFI call), mirroring the Sign + // path. The C copy is separately scrubbed in callBuildTaggedTBTCSignerOperation. + defer zeroBytes(requestPayload) + + responsePayload, err := callBuildTaggedTBTCSignerDKGPart2(requestPayload) + if err != nil { + return nil, err + } + // The response carries the round-2 secret package and the per-recipient + // round-2 packages (secret shares). Scrub the Go-side transport buffer once + // decoded; the decoded values returned to the caller are fresh copies. + defer zeroBytes(responsePayload) + + return decodeBuildTaggedTBTCSignerDKGPart2Response(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) Part3( + secretPackage *NativeFROSTDKGRound2SecretPackage, + round1Packages []*NativeFROSTDKGRound1Package, + round2Packages []*NativeFROSTDKGRound2Package, +) (*NativeFROSTDKGResult, error) { + requestPayload, err := buildTaggedTBTCSignerDKGPart3RequestPayload( + secretPackage, + round1Packages, + round2Packages, + ) + if err != nil { + return nil, err + } + // The request embeds the round-2 secret package and the received round-2 + // packages (incoming secret shares); scrub the Go-side buffer on every + // return path, mirroring the Sign path. The C copy is separately scrubbed + // in callBuildTaggedTBTCSignerOperation. + defer zeroBytes(requestPayload) + + responsePayload, err := callBuildTaggedTBTCSignerDKGPart3(requestPayload) + if err != nil { + return nil, err + } + // The response carries the final key package (the long-term signing share). + // Scrub the Go-side transport buffer once decoded; the decoded key package + // returned to the caller is a fresh copy. + defer zeroBytes(responsePayload) + + return decodeBuildTaggedTBTCSignerDKGPart3Response(responsePayload) +} + +// PersistDistributedDKGKeyPackage stores this node's Part3 key package plus the +// group public key package as signing material the interactive signing path can +// load (keyed by the returned key group). A distributed DKG - unlike the dealer +// RunDKG - leaves each node with only its OWN secret key package, which Part3 +// returns; this persists it so the wallet can sign. +func (bttse *buildTaggedTBTCSignerEngine) PersistDistributedDKGKeyPackage( + sessionID string, + participantIdentifier uint16, + threshold uint16, + participantCount uint16, + keyPackage *NativeFROSTKeyPackage, + publicKeyPackage *NativeFROSTPublicKeyPackage, +) (*NativeTBTCSignerDKGResult, error) { + requestPayload, err := buildTaggedTBTCSignerPersistDistributedDKGKeyPackageRequestPayload( + sessionID, + participantIdentifier, + threshold, + participantCount, + keyPackage, + publicKeyPackage, + ) + if err != nil { + return nil, err + } + // The request embeds this node's serialized key package (secret material); + // scrub the Go-side transport buffer on every return path, mirroring Sign/Part3. + defer zeroBytes(requestPayload) + + responsePayload, err := callBuildTaggedTBTCSignerPersistDistributedDKGKeyPackage(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerRunDKGResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) GenerateNoncesAndCommitments( + keyPackageIdentifier string, + keyPackageData []byte, +) (noncesData []byte, commitmentIdentifier string, commitmentData []byte, err error) { + requestPayload, err := buildTaggedTBTCSignerGenerateNoncesRequestPayload( + keyPackageIdentifier, + keyPackageData, + ) + if err != nil { + return nil, "", nil, err + } + // requestPayload serializes this seat's SECRET signing key package. Scrub the + // Go-side buffer after use (the C-heap copy is wiped in the call helper), matching + // Sign / Part2 / Part3 / PersistDistributedDKGKeyPackage - this was the lone + // secret-bearing op missing the request scrub. + defer zeroBytes(requestPayload) + + responsePayload, err := callBuildTaggedTBTCSignerGenerateNoncesAndCommitments( + requestPayload, + ) + if err != nil { + return nil, "", nil, err + } + defer zeroBytes(responsePayload) + + return decodeBuildTaggedTBTCSignerGenerateNoncesResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) NewSigningPackage( + message []byte, + commitments []nativeFROSTCommitment, +) (signingPackageData []byte, err error) { + requestPayload, err := buildTaggedTBTCSignerNewSigningPackageRequestPayload( + message, + commitments, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerNewSigningPackage(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerNewSigningPackageResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) Sign( + signingPackageData []byte, + noncesData []byte, + keyPackageIdentifier string, + keyPackageData []byte, +) (signatureShareIdentifier string, signatureShareData []byte, err error) { + defer zeroBytes(noncesData) + + requestPayload, err := buildTaggedTBTCSignerSignShareRequestPayload( + signingPackageData, + noncesData, + keyPackageIdentifier, + keyPackageData, + ) + if err != nil { + return "", nil, err + } + defer zeroBytes(requestPayload) + + responsePayload, err := callBuildTaggedTBTCSignerSignShare(requestPayload) + if err != nil { + return "", nil, err + } + + return decodeBuildTaggedTBTCSignerSignShareResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) Aggregate( + signingPackageData []byte, + signatureShares []nativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, +) (signature []byte, err error) { + requestPayload, err := buildTaggedTBTCSignerAggregateRequestPayload( + signingPackageData, + signatureShares, + publicKeyPackage, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerAggregate(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerAggregateResponse(responsePayload) +} + +// VerifySignatureShare re-verifies ONE retained round-2 signature share against +// an attempt's signing package, returning the engine's tri-state verdict. It +// backs the Go host's Round2ShareVerifier (member-blame classifier). On any +// FFI-transport error it returns (NativeShareVerdictIndeterminate, err); the +// caller fails closed to don't-blame. +func (bttse *buildTaggedTBTCSignerEngine) VerifySignatureShare( + sessionID string, + signingPackage []byte, + signatureShare []byte, + memberIdentifier uint16, + taprootMerkleRoot *[32]byte, +) (NativeShareVerificationVerdict, error) { + requestPayload, err := buildTaggedTBTCSignerVerifySignatureShareRequestPayload( + sessionID, + signingPackage, + signatureShare, + memberIdentifier, + taprootMerkleRoot, + ) + if err != nil { + return NativeShareVerdictIndeterminate, err + } + + responsePayload, err := callBuildTaggedTBTCSignerVerifySignatureShare(requestPayload) + if err != nil { + return NativeShareVerdictIndeterminate, err + } + + return decodeBuildTaggedTBTCSignerVerifySignatureShareResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + taprootMerkleRoot *[32]byte, +) (*NativeTBTCSignerRoundState, error) { + requestPayload, err := buildTaggedTBTCSignerStartSignRoundRequestPayload( + sessionID, + memberIdentifier, + message, + keyGroup, + signingParticipants, + taprootMerkleRoot, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerStartSignRound(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerStartSignRoundResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) FinalizeSignRound( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + requestPayload, err := buildTaggedTBTCSignerFinalizeSignRoundRequestPayload( + sessionID, + roundContributions, + taprootMerkleRoot, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerFinalizeSignRound(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerFinalizeSignRoundResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + requestPayload, err := buildTaggedTBTCSignerBuildTaprootTxRequestPayload( + sessionID, + inputs, + outputs, + scriptTreeHex, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerBuildTaprootTx(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerBuildTaprootTxResponse(responsePayload) +} + +func buildTaggedTBTCSignerUnavailableError(operation string) error { + return fmt.Errorf( + "%w: tbtc-signer bridge operation [%v] is unavailable; link libfrost_tbtc", + ErrNativeCryptographyUnavailable, + operation, + ) +} + +func buildTaggedTBTCSignerOperationError( + operation string, + message string, +) error { + return fmt.Errorf( + "%w: tbtc-signer bridge operation [%v] failed: [%s]", + ErrNativeBridgeOperationFailed, + operation, + message, + ) +} + +func buildTaggedTBTCSignerRunDKGRequestPayload( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) ([]byte, error) { + return buildTaggedTBTCSignerRunDKGRequestPayloadWithOptionalSeed( + sessionID, + participants, + threshold, + nil, + ) +} + +func buildTaggedTBTCSignerRunDKGRequestPayloadWithSeed( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + dkgSeedHex string, +) ([]byte, error) { + if dkgSeedHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "DKG seed hex is empty", + ) + } + + return buildTaggedTBTCSignerRunDKGRequestPayloadWithOptionalSeed( + sessionID, + participants, + threshold, + &dkgSeedHex, + ) +} + +func buildTaggedTBTCSignerRunDKGRequestPayloadWithOptionalSeed( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + dkgSeedHex *string, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "session ID is empty", + ) + } + + if len(participants) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "participants are empty", + ) + } + + if threshold == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "threshold is zero", + ) + } + + requestParticipants := make( + []buildTaggedTBTCSignerDKGParticipant, + 0, + len(participants), + ) + + for i, participant := range participants { + if participant.Identifier == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + fmt.Sprintf("participant [%d] identifier is zero", i), + ) + } + + if participant.PublicKeyHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + fmt.Sprintf("participant [%d] public key hex is empty", i), + ) + } + + requestParticipants = append( + requestParticipants, + buildTaggedTBTCSignerDKGParticipant{ + Identifier: participant.Identifier, + PublicKeyHex: participant.PublicKeyHex, + }, + ) + } + + request := buildTaggedTBTCSignerRunDKGRequest{ + SessionID: sessionID, + Participants: requestParticipants, + Threshold: threshold, + DKGSeedHex: dkgSeedHex, + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + fmt.Sprintf("cannot marshal request: %v", err), + ) + } + + return payload, nil +} + +func decodeBuildTaggedTBTCSignerRunDKGResponse( + responsePayload []byte, +) (*NativeTBTCSignerDKGResult, error) { + var response buildTaggedTBTCSignerRunDKGResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + if response.SessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "response session ID is empty", + ) + } + + if response.KeyGroup == "" { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "response key group is empty", + ) + } + + if response.ParticipantCount == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "response participant count is zero", + ) + } + + if response.Threshold == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "response threshold is zero", + ) + } + + return &NativeTBTCSignerDKGResult{ + SessionID: response.SessionID, + KeyGroup: response.KeyGroup, + ParticipantCount: response.ParticipantCount, + Threshold: response.Threshold, + CreatedAtUnix: response.CreatedAtUnix, + }, nil +} + +func buildTaggedTBTCSignerDKGPart1RequestPayload( + participantIdentifier string, + maxSigners uint16, + minSigners uint16, +) ([]byte, error) { + if participantIdentifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart1", + "participant identifier is empty", + ) + } + if maxSigners == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart1", + "max signers is zero", + ) + } + if minSigners == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart1", + "min signers is zero", + ) + } + if minSigners > maxSigners { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart1", + "min signers exceeds max signers", + ) + } + + return buildTaggedTBTCSignerMarshalRequest( + "DKGPart1", + buildTaggedTBTCSignerDKGPart1Request{ + ParticipantIdentifier: participantIdentifier, + MaxSigners: maxSigners, + MinSigners: minSigners, + }, + ) +} + +func decodeBuildTaggedTBTCSignerDKGPart1Response( + responsePayload []byte, +) (*NativeFROSTDKGPart1Result, error) { + var response buildTaggedTBTCSignerDKGPart1Response + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart1", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + secretPackageData, err := buildTaggedTBTCSignerDecodeHexField( + "DKGPart1", + "response secret package", + response.SecretPackageHex, + ) + if err != nil { + return nil, err + } + round1Package, err := decodeBuildTaggedTBTCSignerDKGRound1Package( + "DKGPart1", + "response package", + response.Package, + ) + if err != nil { + return nil, err + } + + return &NativeFROSTDKGPart1Result{ + SecretPackage: &NativeFROSTDKGRound1SecretPackage{ + Data: secretPackageData, + }, + Package: round1Package, + }, nil +} + +func buildTaggedTBTCSignerDKGPart2RequestPayload( + secretPackage *NativeFROSTDKGRound1SecretPackage, + round1Packages []*NativeFROSTDKGRound1Package, +) ([]byte, error) { + if secretPackage == nil { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart2", + "secret package is nil", + ) + } + if len(secretPackage.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart2", + "secret package data is empty", + ) + } + + requestPackages, err := buildTaggedTBTCSignerDKGRound1PackagePayloads( + "DKGPart2", + round1Packages, + ) + if err != nil { + return nil, err + } + + return buildTaggedTBTCSignerMarshalRequest( + "DKGPart2", + buildTaggedTBTCSignerDKGPart2Request{ + SecretPackageHex: hex.EncodeToString(secretPackage.Data), + Round1Packages: requestPackages, + }, + ) +} + +func decodeBuildTaggedTBTCSignerDKGPart2Response( + responsePayload []byte, +) (*NativeFROSTDKGPart2Result, error) { + var response buildTaggedTBTCSignerDKGPart2Response + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart2", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + secretPackageData, err := buildTaggedTBTCSignerDecodeHexField( + "DKGPart2", + "response secret package", + response.SecretPackageHex, + ) + if err != nil { + return nil, err + } + if len(response.Packages) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart2", + "response packages are empty", + ) + } + + packages := make([]*NativeFROSTDKGRound2Package, 0, len(response.Packages)) + for i := range response.Packages { + pkg, err := decodeBuildTaggedTBTCSignerDKGRound2Package( + "DKGPart2", + fmt.Sprintf("response package [%d]", i), + &response.Packages[i], + false, + ) + if err != nil { + return nil, err + } + packages = append(packages, pkg) + } + + return &NativeFROSTDKGPart2Result{ + SecretPackage: &NativeFROSTDKGRound2SecretPackage{ + Data: secretPackageData, + }, + Packages: packages, + }, nil +} + +func buildTaggedTBTCSignerDKGPart3RequestPayload( + secretPackage *NativeFROSTDKGRound2SecretPackage, + round1Packages []*NativeFROSTDKGRound1Package, + round2Packages []*NativeFROSTDKGRound2Package, +) ([]byte, error) { + if secretPackage == nil { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart3", + "secret package is nil", + ) + } + if len(secretPackage.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart3", + "secret package data is empty", + ) + } + + requestRound1Packages, err := buildTaggedTBTCSignerDKGRound1PackagePayloads( + "DKGPart3", + round1Packages, + ) + if err != nil { + return nil, err + } + requestRound2Packages, err := buildTaggedTBTCSignerDKGRound2PackagePayloads( + "DKGPart3", + round2Packages, + true, + ) + if err != nil { + return nil, err + } + + return buildTaggedTBTCSignerMarshalRequest( + "DKGPart3", + buildTaggedTBTCSignerDKGPart3Request{ + SecretPackageHex: hex.EncodeToString(secretPackage.Data), + Round1Packages: requestRound1Packages, + Round2Packages: requestRound2Packages, + }, + ) +} + +func buildTaggedTBTCSignerPersistDistributedDKGKeyPackageRequestPayload( + sessionID string, + participantIdentifier uint16, + threshold uint16, + participantCount uint16, + keyPackage *NativeFROSTKeyPackage, + publicKeyPackage *NativeFROSTPublicKeyPackage, +) ([]byte, error) { + const op = "PersistDistributedDKGKeyPackage" + if keyPackage == nil { + return nil, buildTaggedTBTCSignerOperationError(op, "key package is nil") + } + if len(keyPackage.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError(op, "key package data is empty") + } + if publicKeyPackage == nil { + return nil, buildTaggedTBTCSignerOperationError(op, "public key package is nil") + } + return buildTaggedTBTCSignerMarshalRequest( + op, + buildTaggedTBTCSignerPersistDistributedDKGKeyPackageRequest{ + SessionID: sessionID, + ParticipantIdentifier: participantIdentifier, + Threshold: threshold, + ParticipantCount: participantCount, + KeyPackage: &buildTaggedTBTCSignerNativeFROSTKeyPackage{ + Identifier: keyPackage.Identifier, + DataHex: hex.EncodeToString(keyPackage.Data), + }, + PublicKeyPackage: &buildTaggedTBTCSignerNativeFROSTPublicKeyPackage{ + VerifyingShares: publicKeyPackage.VerifyingShares, + VerifyingKey: publicKeyPackage.VerifyingKey, + }, + }, + ) +} + +func decodeBuildTaggedTBTCSignerDKGPart3Response( + responsePayload []byte, +) (*NativeFROSTDKGResult, error) { + var response buildTaggedTBTCSignerDKGPart3Response + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart3", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + if response.KeyPackage == nil { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart3", + "response key package is nil", + ) + } + if response.KeyPackage.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart3", + "response key package identifier is empty", + ) + } + keyPackageData, err := buildTaggedTBTCSignerDecodeHexField( + "DKGPart3", + "response key package data", + response.KeyPackage.DataHex, + ) + if err != nil { + return nil, err + } + if response.PublicKeyPackage == nil { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart3", + "response public key package is nil", + ) + } + if response.PublicKeyPackage.VerifyingKey == "" { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart3", + "response public key package verifying key is empty", + ) + } + if len(response.PublicKeyPackage.VerifyingShares) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "DKGPart3", + "response public key package verifying shares are empty", + ) + } + + return &NativeFROSTDKGResult{ + KeyPackage: &NativeFROSTKeyPackage{ + Identifier: response.KeyPackage.Identifier, + Data: keyPackageData, + }, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingShares: appendBuildTaggedTBTCSignerStringMap( + response.PublicKeyPackage.VerifyingShares, + ), + VerifyingKey: response.PublicKeyPackage.VerifyingKey, + }, + }, nil +} + +func buildTaggedTBTCSignerGenerateNoncesRequestPayload( + keyPackageIdentifier string, + keyPackageData []byte, +) ([]byte, error) { + if keyPackageIdentifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + "GenerateNoncesAndCommitments", + "key package identifier is empty", + ) + } + if len(keyPackageData) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "GenerateNoncesAndCommitments", + "key package data is empty", + ) + } + + return buildTaggedTBTCSignerMarshalRequest( + "GenerateNoncesAndCommitments", + buildTaggedTBTCSignerGenerateNoncesRequest{ + KeyPackageIdentifier: keyPackageIdentifier, + KeyPackageHex: hex.EncodeToString(keyPackageData), + }, + ) +} + +func decodeBuildTaggedTBTCSignerGenerateNoncesResponse( + responsePayload []byte, +) (noncesData []byte, commitmentIdentifier string, commitmentData []byte, err error) { + var response buildTaggedTBTCSignerGenerateNoncesResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, "", nil, buildTaggedTBTCSignerOperationError( + "GenerateNoncesAndCommitments", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + noncesData, err = buildTaggedTBTCSignerDecodeHexField( + "GenerateNoncesAndCommitments", + "response nonces", + response.NoncesHex, + ) + if err != nil { + return nil, "", nil, err + } + commitment, err := decodeBuildTaggedTBTCSignerCommitment( + "GenerateNoncesAndCommitments", + "response commitment", + response.Commitment, + ) + if err != nil { + return nil, "", nil, err + } + + return noncesData, commitment.Identifier, commitment.Data, nil +} + +func buildTaggedTBTCSignerNewSigningPackageRequestPayload( + message []byte, + commitments []nativeFROSTCommitment, +) ([]byte, error) { + if len(commitments) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "NewSigningPackage", + "commitments are empty", + ) + } + + requestCommitments := make( + []buildTaggedTBTCSignerNativeFROSTCommitment, + 0, + len(commitments), + ) + for i, commitment := range commitments { + if commitment.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + "NewSigningPackage", + fmt.Sprintf("commitment [%d] identifier is empty", i), + ) + } + if len(commitment.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "NewSigningPackage", + fmt.Sprintf("commitment [%d] data is empty", i), + ) + } + requestCommitments = append( + requestCommitments, + buildTaggedTBTCSignerNativeFROSTCommitment{ + Identifier: commitment.Identifier, + DataHex: hex.EncodeToString(commitment.Data), + }, + ) + } + + return buildTaggedTBTCSignerMarshalRequest( + "NewSigningPackage", + buildTaggedTBTCSignerNewSigningPackageRequest{ + MessageHex: hex.EncodeToString(message), + Commitments: requestCommitments, + }, + ) +} + +func decodeBuildTaggedTBTCSignerNewSigningPackageResponse( + responsePayload []byte, +) ([]byte, error) { + var response buildTaggedTBTCSignerNewSigningPackageResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "NewSigningPackage", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + return buildTaggedTBTCSignerDecodeHexField( + "NewSigningPackage", + "response signing package", + response.SigningPackageHex, + ) +} + +func buildTaggedTBTCSignerSignShareRequestPayload( + signingPackageData []byte, + noncesData []byte, + keyPackageIdentifier string, + keyPackageData []byte, +) ([]byte, error) { + if len(signingPackageData) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "SignShare", + "signing package data is empty", + ) + } + if len(noncesData) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "SignShare", + "nonces data is empty", + ) + } + if keyPackageIdentifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + "SignShare", + "key package identifier is empty", + ) + } + if len(keyPackageData) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "SignShare", + "key package data is empty", + ) + } + + return buildTaggedTBTCSignerMarshalRequest( + "SignShare", + buildTaggedTBTCSignerSignShareRequest{ + SigningPackageHex: hex.EncodeToString(signingPackageData), + NoncesHex: hex.EncodeToString(noncesData), + KeyPackageIdentifier: keyPackageIdentifier, + KeyPackageHex: hex.EncodeToString(keyPackageData), + }, + ) +} + +func decodeBuildTaggedTBTCSignerSignShareResponse( + responsePayload []byte, +) (string, []byte, error) { + var response buildTaggedTBTCSignerSignShareResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return "", nil, buildTaggedTBTCSignerOperationError( + "SignShare", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + signatureShare, err := decodeBuildTaggedTBTCSignerSignatureShare( + "SignShare", + "response signature share", + response.SignatureShare, + ) + if err != nil { + return "", nil, err + } + + return signatureShare.Identifier, signatureShare.Data, nil +} + +func buildTaggedTBTCSignerAggregateRequestPayload( + signingPackageData []byte, + signatureShares []nativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, +) ([]byte, error) { + if len(signingPackageData) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "Aggregate", + "signing package data is empty", + ) + } + if len(signatureShares) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "Aggregate", + "signature shares are empty", + ) + } + if publicKeyPackage == nil { + return nil, buildTaggedTBTCSignerOperationError( + "Aggregate", + "public key package is nil", + ) + } + if publicKeyPackage.VerifyingKey == "" { + return nil, buildTaggedTBTCSignerOperationError( + "Aggregate", + "public key package verifying key is empty", + ) + } + if len(publicKeyPackage.VerifyingShares) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "Aggregate", + "public key package verifying shares are empty", + ) + } + + requestShares := make( + []buildTaggedTBTCSignerNativeFROSTSignatureShare, + 0, + len(signatureShares), + ) + for i, signatureShare := range signatureShares { + if signatureShare.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + "Aggregate", + fmt.Sprintf("signature share [%d] identifier is empty", i), + ) + } + if len(signatureShare.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "Aggregate", + fmt.Sprintf("signature share [%d] data is empty", i), + ) + } + requestShares = append( + requestShares, + buildTaggedTBTCSignerNativeFROSTSignatureShare{ + Identifier: signatureShare.Identifier, + DataHex: hex.EncodeToString(signatureShare.Data), + }, + ) + } + + return buildTaggedTBTCSignerMarshalRequest( + "Aggregate", + buildTaggedTBTCSignerAggregateRequest{ + SigningPackageHex: hex.EncodeToString(signingPackageData), + SignatureShares: requestShares, + PublicKeyPackage: &buildTaggedTBTCSignerNativeFROSTPublicKeyPackage{ + VerifyingShares: appendBuildTaggedTBTCSignerStringMap( + publicKeyPackage.VerifyingShares, + ), + VerifyingKey: publicKeyPackage.VerifyingKey, + }, + }, + ) +} + +func decodeBuildTaggedTBTCSignerAggregateResponse( + responsePayload []byte, +) ([]byte, error) { + var response buildTaggedTBTCSignerAggregateResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "Aggregate", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + return buildTaggedTBTCSignerDecodeHexField( + "Aggregate", + "response signature", + response.SignatureHex, + ) +} + +// buildTaggedTBTCSignerVerifySignatureShareRequestPayload builds the +// VerifySignatureShare request. +// +// Unlike every other bridge operation, it deliberately does NOT reject empty or +// short signing-package / signature-share bytes. For THIS operation those bytes +// are the SUBJECT of the engine's tri-state verdict: a member's retained share +// envelope can carry empty or malformed inner FROST bytes (the collector +// authenticates the operator signature over the envelope, not the FROST share +// equation), and the engine classifies such bytes as an `invalid` (blamable) +// verdict. If the bridge instead rejected them with an error here, the Go host +// would map that FFI error to ShareIndeterminate and a cheater who submitted +// garbage would dodge blame. So only the sessionID routing key is validated; +// the package, share, and member identifier are passed through for the engine +// to classify (a malformed package or out-of-range id yields `indeterminate`, +// never false blame). +func buildTaggedTBTCSignerVerifySignatureShareRequestPayload( + sessionID string, + signingPackage []byte, + signatureShare []byte, + memberIdentifier uint16, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "VerifySignatureShare", + "session ID is empty", + ) + } + + var taprootMerkleRootHex *string + if taprootMerkleRoot != nil { + encoded := hex.EncodeToString(taprootMerkleRoot[:]) + taprootMerkleRootHex = &encoded + } + + return buildTaggedTBTCSignerMarshalRequest( + "VerifySignatureShare", + buildTaggedTBTCSignerVerifySignatureShareRequest{ + SessionID: sessionID, + SigningPackageHex: hex.EncodeToString(signingPackage), + SignatureShareHex: hex.EncodeToString(signatureShare), + MemberIdentifier: memberIdentifier, + TaprootMerkleRootHex: taprootMerkleRootHex, + }, + ) +} + +// decodeBuildTaggedTBTCSignerVerifySignatureShareResponse maps the engine's +// snake_case verdict string to the typed tri-state. An unrecognized verdict is +// an error (never silently defaulted); the zero value of the verdict type is the +// safe Indeterminate, so an unchecked error never reads as blame. +func decodeBuildTaggedTBTCSignerVerifySignatureShareResponse( + responsePayload []byte, +) (NativeShareVerificationVerdict, error) { + var response buildTaggedTBTCSignerVerifySignatureShareResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return NativeShareVerdictIndeterminate, buildTaggedTBTCSignerOperationError( + "VerifySignatureShare", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + switch response.Verdict { + case "valid": + return NativeShareVerdictValid, nil + case "invalid": + return NativeShareVerdictInvalid, nil + case "indeterminate": + return NativeShareVerdictIndeterminate, nil + default: + return NativeShareVerdictIndeterminate, buildTaggedTBTCSignerOperationError( + "VerifySignatureShare", + fmt.Sprintf("response verdict is unrecognized: %q", response.Verdict), + ) + } +} + +func buildTaggedTBTCSignerMarshalRequest( + operation string, + request interface{}, +) ([]byte, error) { + payload, err := json.Marshal(request) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("cannot marshal request: %v", err), + ) + } + + return payload, nil +} + +func buildTaggedTBTCSignerDecodeHexField( + operation string, + fieldName string, + value string, +) ([]byte, error) { + if value == "" { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s is empty", fieldName), + ) + } + + data, err := hex.DecodeString(value) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s is invalid hex: %v", fieldName, err), + ) + } + if len(data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s decoded to empty bytes", fieldName), + ) + } + + return data, nil +} + +func buildTaggedTBTCSignerDKGRound1PackagePayloads( + operation string, + packages []*NativeFROSTDKGRound1Package, +) ([]buildTaggedTBTCSignerDKGRound1Package, error) { + if len(packages) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + "round-one packages are empty", + ) + } + + payloads := make( + []buildTaggedTBTCSignerDKGRound1Package, + 0, + len(packages), + ) + for i, pkg := range packages { + if pkg == nil { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("round-one package [%d] is nil", i), + ) + } + if pkg.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("round-one package [%d] identifier is empty", i), + ) + } + if len(pkg.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("round-one package [%d] data is empty", i), + ) + } + payloads = append(payloads, buildTaggedTBTCSignerDKGRound1Package{ + Identifier: pkg.Identifier, + PackageHex: hex.EncodeToString(pkg.Data), + }) + } + + return payloads, nil +} + +func buildTaggedTBTCSignerDKGRound2PackagePayloads( + operation string, + packages []*NativeFROSTDKGRound2Package, + requireSenderIdentifier bool, +) ([]buildTaggedTBTCSignerDKGRound2Package, error) { + if len(packages) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + "round-two packages are empty", + ) + } + + payloads := make( + []buildTaggedTBTCSignerDKGRound2Package, + 0, + len(packages), + ) + for i, pkg := range packages { + if pkg == nil { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("round-two package [%d] is nil", i), + ) + } + if pkg.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("round-two package [%d] identifier is empty", i), + ) + } + if requireSenderIdentifier && pkg.SenderIdentifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("round-two package [%d] sender identifier is empty", i), + ) + } + if len(pkg.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("round-two package [%d] data is empty", i), + ) + } + + var senderIdentifier *string + if pkg.SenderIdentifier != "" { + copied := pkg.SenderIdentifier + senderIdentifier = &copied + } + payloads = append(payloads, buildTaggedTBTCSignerDKGRound2Package{ + Identifier: pkg.Identifier, + SenderIdentifier: senderIdentifier, + PackageHex: hex.EncodeToString(pkg.Data), + }) + } + + return payloads, nil +} + +func decodeBuildTaggedTBTCSignerDKGRound1Package( + operation string, + fieldName string, + pkg *buildTaggedTBTCSignerDKGRound1Package, +) (*NativeFROSTDKGRound1Package, error) { + if pkg == nil { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s is nil", fieldName), + ) + } + if pkg.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s identifier is empty", fieldName), + ) + } + data, err := buildTaggedTBTCSignerDecodeHexField( + operation, + fmt.Sprintf("%s data", fieldName), + pkg.PackageHex, + ) + if err != nil { + return nil, err + } + + return &NativeFROSTDKGRound1Package{ + Identifier: pkg.Identifier, + Data: data, + }, nil +} + +func decodeBuildTaggedTBTCSignerDKGRound2Package( + operation string, + fieldName string, + pkg *buildTaggedTBTCSignerDKGRound2Package, + requireSenderIdentifier bool, +) (*NativeFROSTDKGRound2Package, error) { + if pkg == nil { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s is nil", fieldName), + ) + } + if pkg.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s identifier is empty", fieldName), + ) + } + if requireSenderIdentifier && (pkg.SenderIdentifier == nil || *pkg.SenderIdentifier == "") { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s sender identifier is empty", fieldName), + ) + } + data, err := buildTaggedTBTCSignerDecodeHexField( + operation, + fmt.Sprintf("%s data", fieldName), + pkg.PackageHex, + ) + if err != nil { + return nil, err + } + + senderIdentifier := "" + if pkg.SenderIdentifier != nil { + senderIdentifier = *pkg.SenderIdentifier + } + + return &NativeFROSTDKGRound2Package{ + Identifier: pkg.Identifier, + SenderIdentifier: senderIdentifier, + Data: data, + }, nil +} + +func decodeBuildTaggedTBTCSignerCommitment( + operation string, + fieldName string, + commitment *buildTaggedTBTCSignerNativeFROSTCommitment, +) (*NativeFROSTCommitment, error) { + if commitment == nil { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s is nil", fieldName), + ) + } + if commitment.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s identifier is empty", fieldName), + ) + } + data, err := buildTaggedTBTCSignerDecodeHexField( + operation, + fmt.Sprintf("%s data", fieldName), + commitment.DataHex, + ) + if err != nil { + return nil, err + } + + return &NativeFROSTCommitment{ + Identifier: commitment.Identifier, + Data: data, + }, nil +} + +func decodeBuildTaggedTBTCSignerSignatureShare( + operation string, + fieldName string, + signatureShare *buildTaggedTBTCSignerNativeFROSTSignatureShare, +) (*NativeFROSTSignatureShare, error) { + if signatureShare == nil { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s is nil", fieldName), + ) + } + if signatureShare.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("%s identifier is empty", fieldName), + ) + } + data, err := buildTaggedTBTCSignerDecodeHexField( + operation, + fmt.Sprintf("%s data", fieldName), + signatureShare.DataHex, + ) + if err != nil { + return nil, err + } + + return &NativeFROSTSignatureShare{ + Identifier: signatureShare.Identifier, + Data: data, + }, nil +} + +func appendBuildTaggedTBTCSignerStringMap( + source map[string]string, +) map[string]string { + if source == nil { + return nil + } + + copy := make(map[string]string, len(source)) + for key, value := range source { + copy[key] = value + } + + return copy +} + +func buildTaggedTBTCSignerStartSignRoundRequestPayload( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "session ID is empty", + ) + } + + if keyGroup == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "key group is empty", + ) + } + + if memberIdentifier == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "member identifier is zero", + ) + } + + seenParticipants := make(map[uint16]struct{}, len(signingParticipants)) + for i, participant := range signingParticipants { + if participant == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("signing participant [%d] is zero", i), + ) + } + if _, ok := seenParticipants[participant]; ok { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("signing participant [%d] is duplicated", participant), + ) + } + seenParticipants[participant] = struct{}{} + } + + var taprootMerkleRootHex *string + if taprootMerkleRoot != nil { + encodedTaprootMerkleRoot := hex.EncodeToString(taprootMerkleRoot[:]) + taprootMerkleRootHex = &encodedTaprootMerkleRoot + } + + request := buildTaggedTBTCSignerStartSignRoundRequest{ + SessionID: sessionID, + MemberIdentifier: memberIdentifier, + MessageHex: hex.EncodeToString(message), + KeyGroup: keyGroup, + TaprootMerkleRootHex: taprootMerkleRootHex, + SigningParticipants: append([]uint16{}, signingParticipants...), + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("cannot marshal request: %v", err), + ) + } + + return payload, nil +} + +func decodeBuildTaggedTBTCSignerStartSignRoundResponse( + responsePayload []byte, +) (*NativeTBTCSignerRoundState, error) { + var response buildTaggedTBTCSignerStartSignRoundResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + if response.SessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response session ID is empty", + ) + } + + if response.RoundID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response round ID is empty", + ) + } + + if response.MessageDigestHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response message digest is empty", + ) + } + + seenSigningParticipants := make(map[uint16]struct{}, len(response.SigningParticipants)) + for _, participant := range response.SigningParticipants { + if participant == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response signing participant is zero", + ) + } + + if _, ok := seenSigningParticipants[participant]; ok { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("response signing participant [%d] is duplicated", participant), + ) + } + + seenSigningParticipants[participant] = struct{}{} + } + + var ownContribution *NativeTBTCSignerRoundContribution + if response.OwnContribution != nil { + if response.OwnContribution.Identifier == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response own contribution identifier is zero", + ) + } + + if response.OwnContribution.SignatureShareHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response own contribution signature share is empty", + ) + } + + ownContributionData, err := hex.DecodeString( + response.OwnContribution.SignatureShareHex, + ) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf( + "response own contribution signature share is invalid hex: %v", + err, + ), + ) + } + + ownContribution = &NativeTBTCSignerRoundContribution{ + Identifier: response.OwnContribution.Identifier, + Data: ownContributionData, + } + } + + return &NativeTBTCSignerRoundState{ + SessionID: response.SessionID, + RoundID: response.RoundID, + RequiredContributions: response.RequiredContributions, + MessageDigestHex: response.MessageDigestHex, + SigningParticipants: append([]uint16{}, response.SigningParticipants...), + OwnContribution: ownContribution, + }, nil +} + +func buildTaggedTBTCSignerFinalizeSignRoundRequestPayload( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + "session ID is empty", + ) + } + + if len(roundContributions) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + "round contributions are empty", + ) + } + + payloadContributions := make( + []buildTaggedTBTCSignerFinalizeRoundContribution, + 0, + len(roundContributions), + ) + + for i, contribution := range roundContributions { + if len(contribution.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + fmt.Sprintf("round contribution [%d] data is empty", i), + ) + } + + payloadContributions = append( + payloadContributions, + buildTaggedTBTCSignerFinalizeRoundContribution{ + Identifier: contribution.Identifier, + SignatureShareHex: hex.EncodeToString(contribution.Data), + }, + ) + } + + var taprootMerkleRootHex *string + if taprootMerkleRoot != nil { + encodedTaprootMerkleRoot := hex.EncodeToString(taprootMerkleRoot[:]) + taprootMerkleRootHex = &encodedTaprootMerkleRoot + } + + request := buildTaggedTBTCSignerFinalizeSignRoundRequest{ + SessionID: sessionID, + TaprootMerkleRootHex: taprootMerkleRootHex, + RoundContributions: payloadContributions, + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + fmt.Sprintf("cannot marshal request: %v", err), + ) + } + + return payload, nil +} + +func decodeBuildTaggedTBTCSignerFinalizeSignRoundResponse( + responsePayload []byte, +) ([]byte, error) { + var response buildTaggedTBTCSignerFinalizeSignRoundResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + if response.SignatureHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + "response signature is empty", + ) + } + + signature, err := hex.DecodeString(response.SignatureHex) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + fmt.Sprintf("response signature is invalid hex: %v", err), + ) + } + + return signature, nil +} + +func buildTaggedTBTCSignerBuildTaprootTxRequestPayload( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "session ID is empty", + ) + } + + if len(inputs) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "inputs are empty", + ) + } + + if len(outputs) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "outputs are empty", + ) + } + + requestInputs := make( + []buildTaggedTBTCSignerBuildTaprootTxInput, + 0, + len(inputs), + ) + for i, input := range inputs { + if input.TxIDHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("input [%d] txid hex is empty", i), + ) + } + + requestInputs = append( + requestInputs, + buildTaggedTBTCSignerBuildTaprootTxInput{ + TxIDHex: input.TxIDHex, + Vout: input.Vout, + ValueSats: input.ValueSats, + }, + ) + } + + requestOutputs := make( + []buildTaggedTBTCSignerBuildTaprootTxOutput, + 0, + len(outputs), + ) + for i, output := range outputs { + if output.ScriptPubKeyHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("output [%d] script pubkey hex is empty", i), + ) + } + + requestOutputs = append( + requestOutputs, + buildTaggedTBTCSignerBuildTaprootTxOutput{ + ScriptPubKeyHex: output.ScriptPubKeyHex, + ValueSats: output.ValueSats, + }, + ) + } + + var requestScriptTreeHex *string + if scriptTreeHex != nil { + if *scriptTreeHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "script tree hex is empty", + ) + } + + copied := *scriptTreeHex + requestScriptTreeHex = &copied + } + + request := buildTaggedTBTCSignerBuildTaprootTxRequest{ + SessionID: sessionID, + Inputs: requestInputs, + Outputs: requestOutputs, + ScriptTreeHex: requestScriptTreeHex, + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("cannot marshal request: %v", err), + ) + } + + return payload, nil +} + +func decodeBuildTaggedTBTCSignerBuildTaprootTxResponse( + responsePayload []byte, +) (*NativeTBTCSignerTxResult, error) { + var response buildTaggedTBTCSignerBuildTaprootTxResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + if response.SessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "response session ID is empty", + ) + } + + if response.TxHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "response tx hex is empty", + ) + } + + if _, err := hex.DecodeString(response.TxHex); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("response tx hex is invalid: %v", err), + ) + } + + return &NativeTBTCSignerTxResult{ + SessionID: response.SessionID, + TxHex: response.TxHex, + }, nil +} + +func callBuildTaggedTBTCSignerVersion() ([]byte, error) { + result := C.tbtc_signer_version() + return parseBuildTaggedTBTCSignerResult("Version", result) +} + +// callBuildTaggedTBTCSignerABIVersion fetches the structured FFI contract version. A +// missing frost_tbtc_abi_version symbol surfaces as ErrNativeCryptographyUnavailable +// (the lib predates ABI versioning), which the ABI preflight turns into an explicit +// incompatibility. It deliberately does NOT pass through callBuildTaggedTBTCSignerOperation +// (it takes no request and must not recurse into the ABI gate). +func callBuildTaggedTBTCSignerABIVersion() ([]byte, error) { + result := C.tbtc_signer_abi_version() + return parseBuildTaggedTBTCSignerResult("ABIVersion", result) +} + +func callBuildTaggedTBTCSignerRunDKG( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "RunDKG", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_run_dkg(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerDKGPart1( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "DKGPart1", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_dkg_part1(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerDKGPart2( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "DKGPart2", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_dkg_part2(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerDKGPart3( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "DKGPart3", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_dkg_part3(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerPersistDistributedDKGKeyPackage( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "PersistDistributedDKGKeyPackage", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_persist_distributed_dkg_key_package(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerGenerateNoncesAndCommitments( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "GenerateNoncesAndCommitments", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_generate_nonces_and_commitments( + requestPtr, + requestLen, + ) + }, + ) +} + +func callBuildTaggedTBTCSignerNewSigningPackage( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "NewSigningPackage", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_new_signing_package(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerSignShare( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "SignShare", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_sign_share(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerAggregate( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "Aggregate", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_aggregate(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerVerifySignatureShare( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "VerifySignatureShare", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_verify_signature_share(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerStartSignRound( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "StartSignRound", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_start_sign_round(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerFinalizeSignRound( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "FinalizeSignRound", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_finalize_sign_round(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerBuildTaprootTx( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "BuildTaprootTx", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_build_taproot_tx(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerOperation( + operation string, + requestPayload []byte, + call func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult, +) ([]byte, error) { + // ABI preflight (once per process): every request-taking engine operation funnels + // through here, so a libfrost_tbtc whose FFI contract version is incompatible with + // this bridge fails CLOSED before any contract-sensitive call rather than risking a + // silently misinterpreted struct/JSON contract. The no-arg version/abi-version + // calls bypass this helper, so the check does not recurse. + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + if len(requestPayload) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + "request payload is empty", + ) + } + + requestPtr := C.CBytes(requestPayload) + requestLen := len(requestPayload) + defer func() { + // Scrub the secret request bytes from the C heap before releasing them. + // The request payload can carry signing-share / nonce material, and a + // plain C.free does not overwrite; this mirrors the Go-side zeroBytes + // hygiene applied to the caller's own copy. + zeroBytes(unsafe.Slice((*byte)(requestPtr), requestLen)) + C.free(requestPtr) + }() + + result := call((*C.uint8_t)(requestPtr), C.size_t(len(requestPayload))) + return parseBuildTaggedTBTCSignerResult(operation, result) +} + +func parseBuildTaggedTBTCSignerResult( + operation string, + result C.TbtcSignerResult, +) ([]byte, error) { + // The C wrapper guards against a missing `frost_tbtc_free_buffer` symbol + // but not against a NULL buffer pointer. Status code -1 paths (FFI lib + // unavailable) and any future path that returns an empty buffer can leave + // `result.buffer.ptr == nil`, so skip the deferred free in that case to + // avoid handing a NULL pointer to Rust's `frost_tbtc_free_buffer`. + if result.buffer.ptr != nil { + defer C.tbtc_signer_free_buffer(result.buffer.ptr, result.buffer.len) + } + + statusCode := int32(result.status_code) + + var payload []byte + if result.buffer.ptr != nil && result.buffer.len > 0 { + // Guard the size_t -> C.int narrowing in C.GoBytes: a length that does + // not fit in a C.int (>= 2^31) would overflow to a negative value and + // panic ("length out of range") at the cgo boundary, or silently + // truncate to a wrong length. A response that large is never valid, so + // reject it; the buffer is still released by the deferred free above. + if uint64(result.buffer.len) > uint64(math.MaxInt32) { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf( + "response buffer length [%d] exceeds maximum [%d]", + uint64(result.buffer.len), math.MaxInt32, + ), + ) + } + payload = C.GoBytes(unsafe.Pointer(result.buffer.ptr), C.int(result.buffer.len)) + } + + statusErr := buildTaggedTBTCSignerResultStatusError(operation, statusCode, payload) + if statusErr != nil { + return nil, statusErr + } + + if len(payload) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + "response payload is empty", + ) + } + + return payload, nil +} + +func buildTaggedTBTCSignerResultStatusError( + operation string, + statusCode int32, + payload []byte, +) error { + if statusCode == buildTaggedTBTCSignerUnavailableStatusCode { + return buildTaggedTBTCSignerUnavailableError(operation) + } + + if statusCode != 0 { + structured := buildTaggedTBTCSignerErrorPayload(payload) + return fmt.Errorf( + "%w: tbtc-signer bridge operation [%v] failed: [%w]", + ErrNativeBridgeOperationFailed, + operation, + structured, + ) + } + + return nil +} + +func callBuildTaggedTBTCSignerInitSignerConfig( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "InitSignerConfig", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_init_signer_config(requestPtr, requestLen) + }, + ) +} + +// InstallNativeTBTCSignerConfig installs the tbtc-signer's init-time +// operational configuration via frost_tbtc_init_signer_config. configJSON is +// passed through verbatim: the Rust signer owns the schema and validation +// (unknown fields rejected, enforcement-gated policy combinations validated +// at install, environment ignored wholesale once installed) and the install +// is idempotent for an identical payload while a conflicting re-install is +// rejected. Must be called before the first state-touching signer operation. +// Returns an ErrNativeCryptographyUnavailable-classed error when the loaded +// signer library predates the symbol. +func InstallNativeTBTCSignerConfig( + configJSON []byte, +) (*NativeTBTCSignerInitConfigResult, error) { + responsePayload, err := callBuildTaggedTBTCSignerInitSignerConfig(configJSON) + if err != nil { + return nil, err + } + + result := &NativeTBTCSignerInitConfigResult{} + if err := json.Unmarshal(responsePayload, result); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "InitSignerConfig", + fmt.Sprintf("response decode failed: [%v]", err), + ) + } + + return result, nil +} + +// ---------------------------------------------------------------------------- +// Phase 7.3 interactive signing session bridge: open / round1 / round2 / abort. +// +// The hardened interactive path - unlike the stateless nonce contract, secret +// nonces NEVER cross this boundary: the engine generates, holds, consumes, and +// zeroizes them keyed by (session_id, attempt_id). The caller exchanges only +// public commitments, the coordinator's signing package, and signature shares. +// Additive: no Go caller yet (the orchestrator adopts these in a later +// increment). interactive_aggregate, whose failure path surfaces candidate +// culprits, lands in a separate PR with its own structured error. +// ---------------------------------------------------------------------------- + +type buildTaggedTBTCSignerInteractiveAttemptContext struct { + AttemptNumber uint32 `json:"attempt_number"` + CoordinatorIdentifier uint16 `json:"coordinator_identifier"` + IncludedParticipants []uint16 `json:"included_participants"` + IncludedParticipantsFingerprint string `json:"included_participants_fingerprint"` + AttemptID string `json:"attempt_id"` +} + +type buildTaggedTBTCSignerInteractiveSessionOpenRequest struct { + SessionID string `json:"session_id"` + MemberIdentifier uint16 `json:"member_identifier"` + MessageHex string `json:"message_hex"` + KeyGroup string `json:"key_group"` + Threshold uint16 `json:"threshold"` + TaprootMerkleRootHex *string `json:"taproot_merkle_root_hex,omitempty"` + AttemptContext buildTaggedTBTCSignerInteractiveAttemptContext `json:"attempt_context"` +} + +type buildTaggedTBTCSignerInteractiveSessionOpenResponse struct { + SessionID string `json:"session_id"` + AttemptID string `json:"attempt_id"` + Idempotent bool `json:"idempotent"` +} + +type buildTaggedTBTCSignerInteractiveRound1Request struct { + SessionID string `json:"session_id"` + AttemptID string `json:"attempt_id"` + MemberIdentifier uint16 `json:"member_identifier"` +} + +type buildTaggedTBTCSignerInteractiveRound1Response struct { + CommitmentsHex string `json:"commitments_hex"` +} + +type buildTaggedTBTCSignerInteractiveRound2Request struct { + SessionID string `json:"session_id"` + AttemptID string `json:"attempt_id"` + MemberIdentifier uint16 `json:"member_identifier"` + SigningPackageHex string `json:"signing_package_hex"` +} + +type buildTaggedTBTCSignerInteractiveRound2Response struct { + SessionID string `json:"session_id"` + AttemptID string `json:"attempt_id"` + SignatureShareHex string `json:"signature_share_hex"` +} + +type buildTaggedTBTCSignerInteractiveSessionAbortRequest struct { + SessionID string `json:"session_id"` + AttemptID *string `json:"attempt_id,omitempty"` +} + +type buildTaggedTBTCSignerInteractiveSessionAbortResponse struct { + SessionID string `json:"session_id"` + Aborted bool `json:"aborted"` +} + +func (bttse *buildTaggedTBTCSignerEngine) InteractiveSessionOpen( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + threshold uint16, + taprootMerkleRoot *[32]byte, + attemptContext NativeInteractiveAttemptContext, +) (*NativeInteractiveSessionOpenResult, error) { + requestPayload, err := buildTaggedTBTCSignerInteractiveSessionOpenRequestPayload( + sessionID, + memberIdentifier, + message, + keyGroup, + threshold, + taprootMerkleRoot, + attemptContext, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerInteractiveSessionOpen(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerInteractiveSessionOpenResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) InteractiveRound1( + sessionID string, + attemptID string, + memberIdentifier uint16, +) (commitments []byte, err error) { + requestPayload, err := buildTaggedTBTCSignerInteractiveRound1RequestPayload( + sessionID, + attemptID, + memberIdentifier, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerInteractiveRound1(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerInteractiveRound1Response(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) InteractiveRound2( + sessionID string, + attemptID string, + memberIdentifier uint16, + signingPackage []byte, +) (signatureShare []byte, err error) { + requestPayload, err := buildTaggedTBTCSignerInteractiveRound2RequestPayload( + sessionID, + attemptID, + memberIdentifier, + signingPackage, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerInteractiveRound2(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerInteractiveRound2Response(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) InteractiveSessionAbort( + sessionID string, + attemptID *string, +) (*NativeInteractiveSessionAbortResult, error) { + requestPayload, err := buildTaggedTBTCSignerInteractiveSessionAbortRequestPayload( + sessionID, + attemptID, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerInteractiveSessionAbort(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerInteractiveSessionAbortResponse(responsePayload) +} + +func buildTaggedTBTCSignerInteractiveSessionOpenRequestPayload( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + threshold uint16, + taprootMerkleRoot *[32]byte, + attemptContext NativeInteractiveAttemptContext, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "session ID is empty") + } + if memberIdentifier == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "member identifier is zero") + } + if len(message) == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "message is empty") + } + if keyGroup == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "key group is empty") + } + if threshold == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "threshold is zero") + } + if attemptContext.AttemptID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "attempt context attempt ID is empty") + } + if attemptContext.CoordinatorIdentifier == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "attempt context coordinator identifier is zero") + } + if len(attemptContext.IncludedParticipants) == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "attempt context included participants are empty") + } + + // attempt.AttemptContext numbers attempts 0-based; the engine's wire + // attempt_number is 1-based and rejects 0 ("must be at least 1"). Convert + // here so the first attempt (RFC 0) is sent as wire 1 rather than rejected + // before round 1. The engine subtracts 1 internally for its shuffle math. + wireAttemptNumber := attemptContext.AttemptNumber + 1 + if wireAttemptNumber == 0 { + // attemptContext.AttemptNumber was the max uint32; +1 wrapped to 0. + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveSessionOpen", + "attempt number overflows the 1-based wire encoding", + ) + } + + var taprootMerkleRootHex *string + if taprootMerkleRoot != nil { + encoded := hex.EncodeToString(taprootMerkleRoot[:]) + taprootMerkleRootHex = &encoded + } + + return buildTaggedTBTCSignerMarshalRequest( + "InteractiveSessionOpen", + buildTaggedTBTCSignerInteractiveSessionOpenRequest{ + SessionID: sessionID, + MemberIdentifier: memberIdentifier, + MessageHex: hex.EncodeToString(message), + KeyGroup: keyGroup, + Threshold: threshold, + TaprootMerkleRootHex: taprootMerkleRootHex, + AttemptContext: buildTaggedTBTCSignerInteractiveAttemptContext{ + AttemptNumber: wireAttemptNumber, + CoordinatorIdentifier: attemptContext.CoordinatorIdentifier, + IncludedParticipants: append([]uint16(nil), attemptContext.IncludedParticipants...), + IncludedParticipantsFingerprint: attemptContext.IncludedParticipantsFingerprint, + AttemptID: attemptContext.AttemptID, + }, + }, + ) +} + +func decodeBuildTaggedTBTCSignerInteractiveSessionOpenResponse( + responsePayload []byte, +) (*NativeInteractiveSessionOpenResult, error) { + var response buildTaggedTBTCSignerInteractiveSessionOpenResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveSessionOpen", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + if response.SessionID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "response session ID is empty") + } + if response.AttemptID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionOpen", "response attempt ID is empty") + } + + return &NativeInteractiveSessionOpenResult{ + SessionID: response.SessionID, + AttemptID: response.AttemptID, + Idempotent: response.Idempotent, + }, nil +} + +func buildTaggedTBTCSignerInteractiveRound1RequestPayload( + sessionID string, + attemptID string, + memberIdentifier uint16, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveRound1", "session ID is empty") + } + if attemptID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveRound1", "attempt ID is empty") + } + if memberIdentifier == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveRound1", "member identifier is zero") + } + + return buildTaggedTBTCSignerMarshalRequest( + "InteractiveRound1", + buildTaggedTBTCSignerInteractiveRound1Request{ + SessionID: sessionID, + AttemptID: attemptID, + MemberIdentifier: memberIdentifier, + }, + ) +} + +func decodeBuildTaggedTBTCSignerInteractiveRound1Response( + responsePayload []byte, +) ([]byte, error) { + var response buildTaggedTBTCSignerInteractiveRound1Response + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveRound1", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + return buildTaggedTBTCSignerDecodeHexField( + "InteractiveRound1", + "response commitments", + response.CommitmentsHex, + ) +} + +func buildTaggedTBTCSignerInteractiveRound2RequestPayload( + sessionID string, + attemptID string, + memberIdentifier uint16, + signingPackage []byte, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveRound2", "session ID is empty") + } + if attemptID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveRound2", "attempt ID is empty") + } + if memberIdentifier == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveRound2", "member identifier is zero") + } + if len(signingPackage) == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveRound2", "signing package is empty") + } + + return buildTaggedTBTCSignerMarshalRequest( + "InteractiveRound2", + buildTaggedTBTCSignerInteractiveRound2Request{ + SessionID: sessionID, + AttemptID: attemptID, + MemberIdentifier: memberIdentifier, + SigningPackageHex: hex.EncodeToString(signingPackage), + }, + ) +} + +func decodeBuildTaggedTBTCSignerInteractiveRound2Response( + responsePayload []byte, +) ([]byte, error) { + var response buildTaggedTBTCSignerInteractiveRound2Response + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveRound2", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + return buildTaggedTBTCSignerDecodeHexField( + "InteractiveRound2", + "response signature share", + response.SignatureShareHex, + ) +} + +func buildTaggedTBTCSignerInteractiveSessionAbortRequestPayload( + sessionID string, + attemptID *string, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionAbort", "session ID is empty") + } + + var requestAttemptID *string + if attemptID != nil { + if *attemptID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveSessionAbort", + "attempt ID is set but empty", + ) + } + copied := *attemptID + requestAttemptID = &copied + } + + return buildTaggedTBTCSignerMarshalRequest( + "InteractiveSessionAbort", + buildTaggedTBTCSignerInteractiveSessionAbortRequest{ + SessionID: sessionID, + AttemptID: requestAttemptID, + }, + ) +} + +func decodeBuildTaggedTBTCSignerInteractiveSessionAbortResponse( + responsePayload []byte, +) (*NativeInteractiveSessionAbortResult, error) { + var response buildTaggedTBTCSignerInteractiveSessionAbortResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveSessionAbort", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + if response.SessionID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveSessionAbort", "response session ID is empty") + } + + return &NativeInteractiveSessionAbortResult{ + SessionID: response.SessionID, + Aborted: response.Aborted, + }, nil +} + +func callBuildTaggedTBTCSignerInteractiveSessionOpen(requestPayload []byte) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "InteractiveSessionOpen", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_interactive_session_open(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerInteractiveRound1(requestPayload []byte) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "InteractiveRound1", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_interactive_round1(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerInteractiveRound2(requestPayload []byte) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "InteractiveRound2", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_interactive_round2(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerInteractiveSessionAbort(requestPayload []byte) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "InteractiveSessionAbort", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_interactive_session_abort(requestPtr, requestLen) + }, + ) +} + +// ---------------------------------------------------------------------------- +// Phase 7.3 interactive aggregation bridge. +// +// Aggregates the responsive subset's signature shares for an interactive +// attempt into the BIP-340 signature. The engine resolves the verifying +// material from the session's own DKG state (no public key package crosses +// here). On a share-verification failure it returns the candidate culprits in +// the error payload; InteractiveAggregate surfaces them as a typed +// InteractiveAggregateShareVerificationError for the Go host's envelope-bound +// blame adjudication. Additive: no Go caller yet. +// ---------------------------------------------------------------------------- + +type buildTaggedTBTCSignerInteractiveAggregateRequest struct { + SessionID string `json:"session_id"` + AttemptID string `json:"attempt_id"` + SigningPackageHex string `json:"signing_package_hex"` + SignatureShares []buildTaggedTBTCSignerNativeFROSTSignatureShare `json:"signature_shares"` + TaprootMerkleRootHex *string `json:"taproot_merkle_root_hex,omitempty"` +} + +type buildTaggedTBTCSignerInteractiveAggregateResponse struct { + SessionID string `json:"session_id"` + AttemptID string `json:"attempt_id"` + SignatureHex string `json:"signature_hex"` +} + +func (bttse *buildTaggedTBTCSignerEngine) InteractiveAggregate( + sessionID string, + attemptID string, + signingPackage []byte, + signatureShares []nativeFROSTSignatureShare, + taprootMerkleRoot *[32]byte, +) (signature []byte, err error) { + requestPayload, err := buildTaggedTBTCSignerInteractiveAggregateRequestPayload( + sessionID, + attemptID, + signingPackage, + signatureShares, + taprootMerkleRoot, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerInteractiveAggregate(requestPayload) + if err != nil { + // Surface a share-verification failure as the typed error carrying the + // candidate culprits; any other error passes through unchanged. + return nil, interpretInteractiveAggregateError(sessionID, attemptID, err) + } + + return decodeBuildTaggedTBTCSignerInteractiveAggregateResponse(responsePayload) +} + +func buildTaggedTBTCSignerInteractiveAggregateRequestPayload( + sessionID string, + attemptID string, + signingPackage []byte, + signatureShares []nativeFROSTSignatureShare, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveAggregate", "session ID is empty") + } + if attemptID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveAggregate", "attempt ID is empty") + } + if len(signingPackage) == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveAggregate", "signing package is empty") + } + if len(signatureShares) == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveAggregate", "signature shares are empty") + } + + requestShares := make( + []buildTaggedTBTCSignerNativeFROSTSignatureShare, + 0, + len(signatureShares), + ) + for i, signatureShare := range signatureShares { + if signatureShare.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveAggregate", + fmt.Sprintf("signature share [%d] identifier is empty", i), + ) + } + if len(signatureShare.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveAggregate", + fmt.Sprintf("signature share [%d] data is empty", i), + ) + } + requestShares = append( + requestShares, + buildTaggedTBTCSignerNativeFROSTSignatureShare{ + Identifier: signatureShare.Identifier, + DataHex: hex.EncodeToString(signatureShare.Data), + }, + ) + } + + var taprootMerkleRootHex *string + if taprootMerkleRoot != nil { + encoded := hex.EncodeToString(taprootMerkleRoot[:]) + taprootMerkleRootHex = &encoded + } + + return buildTaggedTBTCSignerMarshalRequest( + "InteractiveAggregate", + buildTaggedTBTCSignerInteractiveAggregateRequest{ + SessionID: sessionID, + AttemptID: attemptID, + SigningPackageHex: hex.EncodeToString(signingPackage), + SignatureShares: requestShares, + TaprootMerkleRootHex: taprootMerkleRootHex, + }, + ) +} + +func decodeBuildTaggedTBTCSignerInteractiveAggregateResponse( + responsePayload []byte, +) ([]byte, error) { + var response buildTaggedTBTCSignerInteractiveAggregateResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveAggregate", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + return buildTaggedTBTCSignerDecodeHexField( + "InteractiveAggregate", + "response signature", + response.SignatureHex, + ) +} + +func callBuildTaggedTBTCSignerInteractiveAggregate(requestPayload []byte) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "InteractiveAggregate", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_interactive_aggregate(requestPtr, requestLen) + }, + ) +} + +// ---------------------------------------------------------------------------- +// Phase 7.3 interactive attempt-context derivation bridge. +// +// Derives the canonical attempt context (coordinator, included-participants +// fingerprint, attempt id) + per-participant FROST identifiers from an attempt's +// public inputs, so the Go host never re-implements the engine's +// domain-separated derivations. Stateless and secret-free; the engine +// re-validates the derived context against the same strict check +// InteractiveSessionOpen runs, so the host can pass the result straight back in. +// Additive: no Go caller yet (the runner wiring is the next increment). +// ---------------------------------------------------------------------------- + +type buildTaggedTBTCSignerDeriveInteractiveAttemptContextRequest struct { + SessionID string `json:"session_id"` + MessageHex string `json:"message_hex"` + KeyGroup string `json:"key_group"` + Threshold uint16 `json:"threshold"` + AttemptNumber uint32 `json:"attempt_number"` + IncludedParticipants []uint16 `json:"included_participants"` +} + +type buildTaggedTBTCSignerParticipantFrostIdentifier struct { + ParticipantIdentifier uint16 `json:"participant_identifier"` + FrostIdentifier string `json:"frost_identifier"` +} + +type buildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse struct { + AttemptContext buildTaggedTBTCSignerInteractiveAttemptContext `json:"attempt_context"` + FrostIdentifiers []buildTaggedTBTCSignerParticipantFrostIdentifier `json:"frost_identifiers"` +} + +func (bttse *buildTaggedTBTCSignerEngine) DeriveInteractiveAttemptContext( + sessionID string, + message []byte, + keyGroup string, + threshold uint16, + attemptNumber uint32, + includedParticipants []uint16, +) (*NativeDeriveInteractiveAttemptContextResult, error) { + requestPayload, err := buildTaggedTBTCSignerDeriveInteractiveAttemptContextRequestPayload( + sessionID, + message, + keyGroup, + threshold, + attemptNumber, + includedParticipants, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerDeriveInteractiveAttemptContext(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse(responsePayload) +} + +func buildTaggedTBTCSignerDeriveInteractiveAttemptContextRequestPayload( + sessionID string, + message []byte, + keyGroup string, + threshold uint16, + attemptNumber uint32, + includedParticipants []uint16, +) ([]byte, error) { + const operation = "DeriveInteractiveAttemptContext" + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError(operation, "session ID is empty") + } + if len(message) == 0 { + return nil, buildTaggedTBTCSignerOperationError(operation, "message is empty") + } + if keyGroup == "" { + return nil, buildTaggedTBTCSignerOperationError(operation, "key group is empty") + } + if threshold == 0 { + return nil, buildTaggedTBTCSignerOperationError(operation, "threshold is zero") + } + if len(includedParticipants) == 0 { + return nil, buildTaggedTBTCSignerOperationError(operation, "included participants are empty") + } + + // attempt.AttemptContext numbers attempts 0-based; the engine's wire + // attempt_number is 1-based and rejects 0. Convert here so the first attempt + // (RFC 0) is sent as wire 1, matching InteractiveSessionOpen. + wireAttemptNumber := attemptNumber + 1 + if wireAttemptNumber == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + "attempt number overflows the 1-based wire encoding", + ) + } + + return buildTaggedTBTCSignerMarshalRequest( + operation, + buildTaggedTBTCSignerDeriveInteractiveAttemptContextRequest{ + SessionID: sessionID, + MessageHex: hex.EncodeToString(message), + KeyGroup: keyGroup, + Threshold: threshold, + AttemptNumber: wireAttemptNumber, + IncludedParticipants: append([]uint16(nil), includedParticipants...), + }, + ) +} + +func decodeBuildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse( + responsePayload []byte, +) (*NativeDeriveInteractiveAttemptContextResult, error) { + const operation = "DeriveInteractiveAttemptContext" + var response buildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + attemptContext := response.AttemptContext + if attemptContext.AttemptID == "" { + return nil, buildTaggedTBTCSignerOperationError(operation, "response attempt context attempt ID is empty") + } + if attemptContext.CoordinatorIdentifier == 0 { + return nil, buildTaggedTBTCSignerOperationError(operation, "response attempt context coordinator identifier is zero") + } + // The engine's wire attempt_number is 1-based; 0 is impossible and would + // underflow the conversion below. + if attemptContext.AttemptNumber == 0 { + return nil, buildTaggedTBTCSignerOperationError(operation, "response attempt context attempt number is zero") + } + if len(attemptContext.IncludedParticipants) == 0 { + return nil, buildTaggedTBTCSignerOperationError(operation, "response attempt context included participants are empty") + } + // The engine returns exactly one FROST identifier per included participant + // (canonical order); a mismatch is a malformed response the host must not + // silently consume - downstream signing-package/aggregate keying depends on + // the 1:1 correspondence. + if len(response.FrostIdentifiers) != len(attemptContext.IncludedParticipants) { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf( + "response has [%d] frost identifiers for [%d] included participants", + len(response.FrostIdentifiers), + len(attemptContext.IncludedParticipants), + ), + ) + } + + frostIdentifiers := make([]NativeFROSTParticipantIdentifier, 0, len(response.FrostIdentifiers)) + for i, entry := range response.FrostIdentifiers { + if entry.FrostIdentifier == "" { + return nil, buildTaggedTBTCSignerOperationError(operation, "response frost identifier is empty") + } + // The engine returns identifiers in canonical participant order, one per + // included participant. Bind each entry to the participant at its position: + // a matching count alone still lets a duplicate, zero, reordered, or + // foreign participant_identifier through, yielding a mapping that diverges + // from included_participants - which the runner keys commitments and shares + // by. (Index is in bounds: the count-match check above pins the lengths.) + if entry.ParticipantIdentifier != attemptContext.IncludedParticipants[i] { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf( + "response frost identifier [%d] is for participant [%d], expected [%d]", + i, + entry.ParticipantIdentifier, + attemptContext.IncludedParticipants[i], + ), + ) + } + frostIdentifiers = append(frostIdentifiers, NativeFROSTParticipantIdentifier{ + ParticipantIdentifier: entry.ParticipantIdentifier, + FrostIdentifier: entry.FrostIdentifier, + }) + } + + return &NativeDeriveInteractiveAttemptContextResult{ + AttemptContext: NativeInteractiveAttemptContext{ + // Wire 1-based -> RFC-21 0-based, the inverse of the request encoding, + // so the host receives the natural attempt.AttemptContext value. + AttemptNumber: attemptContext.AttemptNumber - 1, + CoordinatorIdentifier: attemptContext.CoordinatorIdentifier, + IncludedParticipants: append([]uint16(nil), attemptContext.IncludedParticipants...), + IncludedParticipantsFingerprint: attemptContext.IncludedParticipantsFingerprint, + AttemptID: attemptContext.AttemptID, + }, + FrostIdentifiers: frostIdentifiers, + }, nil +} + +func callBuildTaggedTBTCSignerDeriveInteractiveAttemptContext(requestPayload []byte) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "DeriveInteractiveAttemptContext", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_derive_interactive_attempt_context(requestPtr, requestLen) + }, + ) +} diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go new file mode 100644 index 0000000000..91323ad524 --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go @@ -0,0 +1,2109 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" +) + +func TestRegisterBuildTaggedTBTCSignerEngine(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(ResetInteractiveSigningEngineProviderForTest) + + err := registerBuildTaggedNativeFROSTSigningEngine() + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + engine := currentNativeTBTCSignerEngine() + if engine == nil { + t.Fatal("expected native tbtc-signer engine registration") + } + + // RFC-21 Phase 7.3: the same registration installs the cgo engine as the + // interactive signing provider, so the executor can obtain a real engine for + // the gated interactive ROAST path. The provider is a factory returning a + // fresh cgo bridge handle; the path itself stays dormant behind the default-off + // KEEP_CORE_FROST_INTERACTIVE_SIGNING_ENABLED opt-in. + interactive := registeredInteractiveSigningEngine() + if interactive == nil { + t.Fatal("expected the interactive signing provider to be registered") + } + if _, ok := interactive.(*buildTaggedTBTCSignerEngine); !ok { + t.Fatalf( + "interactive provider returned %T, want *buildTaggedTBTCSignerEngine", + interactive, + ) + } + + // The fail-closed contract asserted below - every engine operation returns + // ErrNativeCryptographyUnavailable - only holds when libfrost_tbtc is NOT linked: + // the cgo bridge is compiled (this file's build tag) but the frost_tbtc_* symbols + // are not resolvable via dlsym. Under the frost-cgo-integration gate the lib IS + // linked, so native crypto is available and StartSignRound instead reaches the real + // signer (and its provenance gate); asserting an unavailable error there would be a + // false failure. Probe the linked lib with the same check the ABI preflight uses - + // it keeps ErrNativeCryptographyUnavailable in the chain iff the lib is absent - and + // skip the fail-closed assertions with a reason when the lib is present. The + // registration wiring above still runs under both builds, and the linked-lib crypto + // path is covered by TestBuildTaggedTBTCSignerInteractiveFROSTBridge_WithLinkedSigner + // and the TestRealCgoInteractiveSigning* suite. + if abiErr := assertTBTCSignerABICompatible(); !errors.Is( + abiErr, ErrNativeCryptographyUnavailable, + ) { + t.Skipf( + "libfrost_tbtc linked (native crypto available; ABI probe: %v); the "+ + "fail-closed-unavailable path this test asserts is not exercisable with "+ + "the lib present", + abiErr, + ) + } + + _, err = engine.StartSignRound( + "session-1", + 1, + []byte("message"), + "key-group", + nil, + nil, + ) + if err == nil { + t.Fatal("expected unavailable tbtc-signer bridge error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected native cryptography unavailable error: [%v], got [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !strings.Contains(err.Error(), "unavailable") { + t.Fatalf("unexpected bridge error: [%v]", err) + } + + _, err = engine.BuildTaprootTx( + "session-1", + []NativeTBTCSignerTxInput{ + {TxIDHex: "11", Vout: 0, ValueSats: 1}, + }, + []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014", ValueSats: 1}, + }, + nil, + ) + if err == nil { + t.Fatal("expected unavailable tbtc-signer build-tx bridge error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected native cryptography unavailable error: [%v], got [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + versionedEngine, ok := engine.(interface { + Version() (string, error) + }) + if !ok { + t.Fatal("expected versioned native tbtc-signer engine") + } + + _, err = versionedEngine.Version() + if err == nil { + t.Fatal("expected unavailable tbtc-signer version bridge error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected native cryptography unavailable error: [%v], got [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } +} + +func TestBuildTaggedTBTCSignerInteractiveFROSTBridge_WithLinkedSigner(t *testing.T) { + t.Setenv("TBTC_SIGNER_PROFILE", "development") + t.Setenv("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false") + + engine := &buildTaggedTBTCSignerEngine{} + participantIDs := []byte{1, 2, 3} + participantIdentifiers := make(map[byte]string, len(participantIDs)) + for _, participantID := range participantIDs { + participantIdentifiers[participantID] = buildTaggedTBTCSignerTestIdentifier( + participantID, + ) + } + + part1Results := make(map[byte]*NativeFROSTDKGPart1Result, len(participantIDs)) + for _, participantID := range participantIDs { + result, err := engine.Part1( + participantIdentifiers[participantID], + 3, + 2, + ) + if err != nil { + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Skip("linked tbtc-signer FFI symbols unavailable") + } + t.Fatalf("unexpected DKG part1 error: [%v]", err) + } + if result.Package.Identifier != participantIdentifiers[participantID] { + t.Fatalf("unexpected DKG part1 identifier: [%s]", result.Package.Identifier) + } + part1Results[participantID] = result + } + + part2Results := make(map[byte]*NativeFROSTDKGPart2Result, len(participantIDs)) + for _, participantID := range participantIDs { + round1Packages := make([]*NativeFROSTDKGRound1Package, 0, 2) + for _, otherParticipantID := range participantIDs { + if otherParticipantID == participantID { + continue + } + round1Packages = append( + round1Packages, + part1Results[otherParticipantID].Package, + ) + } + + result, err := engine.Part2( + part1Results[participantID].SecretPackage, + round1Packages, + ) + if err != nil { + t.Fatalf("unexpected DKG part2 error: [%v]", err) + } + if len(result.Packages) != 2 { + t.Fatalf("unexpected DKG part2 package count: [%d]", len(result.Packages)) + } + part2Results[participantID] = result + } + + part3Results := make(map[byte]*NativeFROSTDKGResult, len(participantIDs)) + for _, participantID := range participantIDs { + round1Packages := make([]*NativeFROSTDKGRound1Package, 0, 2) + for _, otherParticipantID := range participantIDs { + if otherParticipantID == participantID { + continue + } + round1Packages = append( + round1Packages, + part1Results[otherParticipantID].Package, + ) + } + + round2Packages := make([]*NativeFROSTDKGRound2Package, 0, 2) + for _, senderParticipantID := range participantIDs { + if senderParticipantID == participantID { + continue + } + var packageForRecipient *NativeFROSTDKGRound2Package + for _, pkg := range part2Results[senderParticipantID].Packages { + if pkg.Identifier == participantIdentifiers[participantID] { + packageForRecipient = pkg + break + } + } + if packageForRecipient == nil { + t.Fatalf( + "missing DKG round2 package from [%d] to [%d]", + senderParticipantID, + participantID, + ) + } + copied := *packageForRecipient + copied.SenderIdentifier = participantIdentifiers[senderParticipantID] + round2Packages = append(round2Packages, &copied) + } + + result, err := engine.Part3( + part2Results[participantID].SecretPackage, + round1Packages, + round2Packages, + ) + if err != nil { + t.Fatalf("unexpected DKG part3 error: [%v]", err) + } + if result.KeyPackage.Identifier != participantIdentifiers[participantID] { + t.Fatalf("unexpected DKG key package identifier") + } + if len(result.PublicKeyPackage.VerifyingKey) != 64 { + t.Fatalf( + "unexpected DKG x-only verifying key length: [%d]", + len(result.PublicKeyPackage.VerifyingKey), + ) + } + if len(result.PublicKeyPackage.VerifyingShares) != 3 { + t.Fatalf( + "unexpected DKG verifying share count: [%d]", + len(result.PublicKeyPackage.VerifyingShares), + ) + } + part3Results[participantID] = result + } + + verifyingKey := part3Results[1].PublicKeyPackage.VerifyingKey + for _, participantID := range participantIDs { + if part3Results[participantID].PublicKeyPackage.VerifyingKey != verifyingKey { + t.Fatal("DKG participants produced different group verifying keys") + } + } + + signingParticipants := []byte{1, 2} + commitments := make([]nativeFROSTCommitment, 0, len(signingParticipants)) + noncesByParticipant := make(map[byte][]byte, len(signingParticipants)) + for _, participantID := range signingParticipants { + nonces, commitmentIdentifier, commitmentData, err := + engine.GenerateNoncesAndCommitments( + part3Results[participantID].KeyPackage.Identifier, + part3Results[participantID].KeyPackage.Data, + ) + if err != nil { + t.Fatalf("unexpected nonce generation error: [%v]", err) + } + commitments = append(commitments, nativeFROSTCommitment{ + Identifier: commitmentIdentifier, + Data: commitmentData, + }) + noncesByParticipant[participantID] = nonces + } + + message := bytesOf(0x42, 32) + signingPackage, err := engine.NewSigningPackage(message, commitments) + if err != nil { + t.Fatalf("unexpected signing package error: [%v]", err) + } + + signatureShares := make( + []nativeFROSTSignatureShare, + 0, + len(signingParticipants), + ) + for _, participantID := range signingParticipants { + signatureShareIdentifier, signatureShareData, err := engine.Sign( + signingPackage, + noncesByParticipant[participantID], + part3Results[participantID].KeyPackage.Identifier, + part3Results[participantID].KeyPackage.Data, + ) + if err != nil { + t.Fatalf("unexpected signature share error: [%v]", err) + } + signatureShares = append(signatureShares, nativeFROSTSignatureShare{ + Identifier: signatureShareIdentifier, + Data: signatureShareData, + }) + } + + signatureBytes, err := engine.Aggregate( + signingPackage, + signatureShares, + part3Results[1].PublicKeyPackage, + ) + if err != nil { + t.Fatalf("unexpected aggregate error: [%v]", err) + } + if len(signatureBytes) != 64 { + t.Fatalf("unexpected aggregate signature length: [%d]", len(signatureBytes)) + } + + publicKeyBytes, err := hex.DecodeString(verifyingKey) + if err != nil { + t.Fatalf("cannot decode verifying key: [%v]", err) + } + publicKey, err := schnorr.ParsePubKey(publicKeyBytes) + if err != nil { + t.Fatalf("cannot parse verifying key: [%v]", err) + } + signature, err := schnorr.ParseSignature(signatureBytes) + if err != nil { + t.Fatalf("cannot parse aggregate signature: [%v]", err) + } + if !signature.Verify(message, publicKey) { + t.Fatal("aggregate signature does not verify under DKG x-only key") + } +} + +func buildTaggedTBTCSignerTestIdentifier(memberIndex byte) string { + identifier := make([]byte, 32) + identifier[0] = memberIndex + return fmt.Sprintf("%q", hex.EncodeToString(identifier)) +} + +func bytesOf(value byte, length int) []byte { + bytes := make([]byte, length) + for i := range bytes { + bytes[i] = value + } + return bytes +} + +func TestBuildTaggedTBTCSignerResultStatusError_Unavailable(t *testing.T) { + err := buildTaggedTBTCSignerResultStatusError( + "BuildTaprootTx", + buildTaggedTBTCSignerUnavailableStatusCode, + nil, + ) + if err == nil { + t.Fatal("expected unavailable error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected native cryptography unavailable error: [%v], got [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "did not expect native bridge operation failed error: [%v]", + err, + ) + } +} + +func TestBuildTaggedTBTCSignerResultStatusError_BridgeOperationFailure(t *testing.T) { + err := buildTaggedTBTCSignerResultStatusError( + "BuildTaprootTx", + 2, + []byte(`{"code":"validation","message":"invalid input"}`), + ) + if err == nil { + t.Fatal("expected bridge operation failure error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } + + if !strings.Contains(err.Error(), "validation: invalid input") { + t.Fatalf("unexpected bridge operation error: [%v]", err) + } +} + +func TestBuildTaggedTBTCSignerResultStatusError_BridgeOperationFailure_InvalidPayload( + t *testing.T, +) { + err := buildTaggedTBTCSignerResultStatusError( + "BuildTaprootTx", + 2, + []byte("{invalid-json"), + ) + if err == nil { + t.Fatal("expected bridge operation failure error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if !strings.Contains(err.Error(), "cannot decode error payload") { + t.Fatalf("unexpected bridge operation error: [%v]", err) + } +} + +func TestBuildTaggedTBTCSignerResultStatusError_BridgeOperationFailure_FallbackPayload( + t *testing.T, +) { + err := buildTaggedTBTCSignerResultStatusError( + "BuildTaprootTx", + 2, + []byte(`{"code":"internal_error","message":"failed to encode error"}`), + ) + if err == nil { + t.Fatal("expected bridge operation failure error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if !strings.Contains(err.Error(), "internal_error: failed to encode error") { + t.Fatalf("unexpected bridge operation error: [%v]", err) + } +} + +func TestBuildTaggedTBTCSignerRunDKGRequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerRunDKGRequestPayload( + "session-1", + []NativeTBTCSignerDKGParticipant{ + { + Identifier: 1, + PublicKeyHex: "02aa", + }, + { + Identifier: 2, + PublicKeyHex: "02bb", + }, + }, + 2, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerRunDKGRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + request.SessionID, + ) + } + + if request.Threshold != 2 { + t.Fatalf( + "unexpected threshold\nexpected: [%v]\nactual: [%v]", + 2, + request.Threshold, + ) + } + + if len(request.Participants) != 2 { + t.Fatalf( + "unexpected participants count\nexpected: [%v]\nactual: [%v]", + 2, + len(request.Participants), + ) + } + + if request.Participants[0].Identifier != 1 { + t.Fatalf( + "unexpected participant identifier\nexpected: [%v]\nactual: [%v]", + 1, + request.Participants[0].Identifier, + ) + } + + if request.Participants[0].PublicKeyHex != "02aa" { + t.Fatalf( + "unexpected participant public key hex\nexpected: [%v]\nactual: [%v]", + "02aa", + request.Participants[0].PublicKeyHex, + ) + } + + if request.DKGSeedHex != nil { + t.Fatalf("unexpected DKG seed hex: [%v]", *request.DKGSeedHex) + } +} + +func TestBuildTaggedTBTCSignerRunDKGRequestPayloadWithSeed(t *testing.T) { + payload, err := buildTaggedTBTCSignerRunDKGRequestPayloadWithSeed( + "session-1", + []NativeTBTCSignerDKGParticipant{ + { + Identifier: 1, + PublicKeyHex: "02aa", + }, + { + Identifier: 2, + PublicKeyHex: "02bb", + }, + }, + 2, + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerRunDKGRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.DKGSeedHex == nil { + t.Fatal("expected DKG seed hex") + } + if *request.DKGSeedHex != + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" { + t.Fatalf("unexpected DKG seed hex: [%v]", *request.DKGSeedHex) + } +} + +func TestBuildTaggedTBTCSignerRunDKGRequestPayload_RejectsInvalidInput(t *testing.T) { + testCases := []struct { + name string + sessionID string + participants []NativeTBTCSignerDKGParticipant + threshold uint16 + }{ + { + name: "empty session id", + sessionID: "", + participants: []NativeTBTCSignerDKGParticipant{{Identifier: 1, PublicKeyHex: "02aa"}}, + threshold: 2, + }, + { + name: "empty participants", + sessionID: "session-1", + participants: nil, + threshold: 2, + }, + { + name: "zero threshold", + sessionID: "session-1", + participants: []NativeTBTCSignerDKGParticipant{ + {Identifier: 1, PublicKeyHex: "02aa"}, + }, + threshold: 0, + }, + { + name: "participant zero identifier", + sessionID: "session-1", + participants: []NativeTBTCSignerDKGParticipant{ + {Identifier: 0, PublicKeyHex: "02aa"}, + }, + threshold: 1, + }, + { + name: "participant empty public key hex", + sessionID: "session-1", + participants: []NativeTBTCSignerDKGParticipant{ + {Identifier: 1, PublicKeyHex: ""}, + }, + threshold: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := buildTaggedTBTCSignerRunDKGRequestPayload( + tc.sessionID, + tc.participants, + tc.threshold, + ) + if err == nil { + t.Fatal("expected payload build error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerRunDKGResponse(t *testing.T) { + result, err := decodeBuildTaggedTBTCSignerRunDKGResponse( + []byte( + `{"session_id":"session-1","key_group":"group-1","participant_count":3,"threshold":2,"created_at_unix":123456789}`, + ), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if result.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + result.SessionID, + ) + } + + if result.KeyGroup != "group-1" { + t.Fatalf( + "unexpected key group\nexpected: [%v]\nactual: [%v]", + "group-1", + result.KeyGroup, + ) + } + + if result.ParticipantCount != 3 { + t.Fatalf( + "unexpected participant count\nexpected: [%v]\nactual: [%v]", + 3, + result.ParticipantCount, + ) + } + + if result.Threshold != 2 { + t.Fatalf( + "unexpected threshold\nexpected: [%v]\nactual: [%v]", + 2, + result.Threshold, + ) + } + + if result.CreatedAtUnix != 123456789 { + t.Fatalf( + "unexpected created-at unix\nexpected: [%v]\nactual: [%v]", + 123456789, + result.CreatedAtUnix, + ) + } +} + +func TestBuildTaggedTBTCSignerStartSignRoundRequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerStartSignRoundRequestPayload( + "session-1", + 3, + []byte{0xab, 0xcd}, + "key-group-1", + []uint16{1, 2, 3}, + nil, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerStartSignRoundRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + request.SessionID, + ) + } + + if request.MessageHex != "abcd" { + t.Fatalf( + "unexpected message hex\nexpected: [%v]\nactual: [%v]", + "abcd", + request.MessageHex, + ) + } + + if request.KeyGroup != "key-group-1" { + t.Fatalf( + "unexpected key group\nexpected: [%v]\nactual: [%v]", + "key-group-1", + request.KeyGroup, + ) + } + + if request.MemberIdentifier != 3 { + t.Fatalf( + "unexpected member identifier\nexpected: [%v]\nactual: [%v]", + 3, + request.MemberIdentifier, + ) + } + + if len(request.SigningParticipants) != 3 { + t.Fatalf( + "unexpected signing participants count\nexpected: [%v]\nactual: [%v]", + 3, + len(request.SigningParticipants), + ) + } + + expectedSigningParticipants := []uint16{1, 2, 3} + for i := range expectedSigningParticipants { + if request.SigningParticipants[i] != expectedSigningParticipants[i] { + t.Fatalf( + "unexpected signing participant at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedSigningParticipants[i], + request.SigningParticipants[i], + ) + } + } +} + +func TestBuildTaggedTBTCSignerStartSignRoundRequestPayload_TaprootMerkleRoot( + t *testing.T, +) { + var taprootMerkleRoot [32]byte + taprootMerkleRoot[0] = 0xab + taprootMerkleRoot[31] = 0xcd + + payload, err := buildTaggedTBTCSignerStartSignRoundRequestPayload( + "session-1", + 3, + []byte{0xab, 0xcd}, + "key-group-1", + []uint16{1, 2, 3}, + &taprootMerkleRoot, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerStartSignRoundRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.TaprootMerkleRootHex == nil { + t.Fatal("expected taproot merkle root") + } + + expectedTaprootMerkleRootHex := hex.EncodeToString(taprootMerkleRoot[:]) + if *request.TaprootMerkleRootHex != expectedTaprootMerkleRootHex { + t.Fatalf( + "unexpected taproot merkle root\nexpected: [%v]\nactual: [%v]", + expectedTaprootMerkleRootHex, + *request.TaprootMerkleRootHex, + ) + } +} + +func TestBuildTaggedTBTCSignerStartSignRoundRequestPayload_EmptySessionID(t *testing.T) { + _, err := buildTaggedTBTCSignerStartSignRoundRequestPayload( + "", + 1, + []byte{0xab}, + "key-group-1", + nil, + nil, + ) + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestBuildTaggedTBTCSignerStartSignRoundRequestPayload_ZeroMemberID(t *testing.T) { + _, err := buildTaggedTBTCSignerStartSignRoundRequestPayload( + "session-1", + 0, + []byte{0xab}, + "key-group-1", + nil, + nil, + ) + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestBuildTaggedTBTCSignerFinalizeSignRoundRequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerFinalizeSignRoundRequestPayload( + "session-1", + []NativeTBTCSignerRoundContribution{ + { + Identifier: 7, + Data: []byte{0xde, 0xad}, + }, + }, + nil, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerFinalizeSignRoundRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + request.SessionID, + ) + } + + if len(request.RoundContributions) != 1 { + t.Fatalf( + "unexpected contribution count\nexpected: [%v]\nactual: [%v]", + 1, + len(request.RoundContributions), + ) + } + + if request.RoundContributions[0].Identifier != 7 { + t.Fatalf( + "unexpected contribution identifier\nexpected: [%v]\nactual: [%v]", + 7, + request.RoundContributions[0].Identifier, + ) + } + + if request.RoundContributions[0].SignatureShareHex != "dead" { + t.Fatalf( + "unexpected contribution signature share\nexpected: [%v]\nactual: [%v]", + "dead", + request.RoundContributions[0].SignatureShareHex, + ) + } +} + +func TestBuildTaggedTBTCSignerFinalizeSignRoundRequestPayload_TaprootMerkleRoot( + t *testing.T, +) { + var taprootMerkleRoot [32]byte + taprootMerkleRoot[0] = 0xab + taprootMerkleRoot[31] = 0xcd + + payload, err := buildTaggedTBTCSignerFinalizeSignRoundRequestPayload( + "session-1", + []NativeTBTCSignerRoundContribution{ + { + Identifier: 7, + Data: []byte{0xde, 0xad}, + }, + }, + &taprootMerkleRoot, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerFinalizeSignRoundRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.TaprootMerkleRootHex == nil { + t.Fatal("expected taproot merkle root") + } + + expectedTaprootMerkleRootHex := hex.EncodeToString(taprootMerkleRoot[:]) + if *request.TaprootMerkleRootHex != expectedTaprootMerkleRootHex { + t.Fatalf( + "unexpected taproot merkle root\nexpected: [%v]\nactual: [%v]", + expectedTaprootMerkleRootHex, + *request.TaprootMerkleRootHex, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerStartSignRoundResponse(t *testing.T) { + roundState, err := decodeBuildTaggedTBTCSignerStartSignRoundResponse( + []byte( + `{"session_id":"session-1","round_id":"round-1","required_contributions":2,"message_digest_hex":"abcd","signing_participants":[1,2,3],"own_contribution":{"identifier":3,"signature_share_hex":"deadbeef"}}`, + ), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if roundState.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + roundState.SessionID, + ) + } + + if roundState.RoundID != "round-1" { + t.Fatalf( + "unexpected round id\nexpected: [%v]\nactual: [%v]", + "round-1", + roundState.RoundID, + ) + } + + if roundState.RequiredContributions != 2 { + t.Fatalf( + "unexpected required contributions\nexpected: [%v]\nactual: [%v]", + 2, + roundState.RequiredContributions, + ) + } + + if roundState.MessageDigestHex != "abcd" { + t.Fatalf( + "unexpected message digest hex\nexpected: [%v]\nactual: [%v]", + "abcd", + roundState.MessageDigestHex, + ) + } + + if len(roundState.SigningParticipants) != 3 { + t.Fatalf( + "unexpected signing participants count\nexpected: [%v]\nactual: [%v]", + 3, + len(roundState.SigningParticipants), + ) + } + + if roundState.OwnContribution == nil { + t.Fatal("expected own contribution in round state response") + } + + if roundState.OwnContribution.Identifier != 3 { + t.Fatalf( + "unexpected own contribution identifier\nexpected: [%v]\nactual: [%v]", + 3, + roundState.OwnContribution.Identifier, + ) + } + + expectedOwnContributionData := []byte{0xde, 0xad, 0xbe, 0xef} + if len(roundState.OwnContribution.Data) != len(expectedOwnContributionData) { + t.Fatalf( + "unexpected own contribution data length\nexpected: [%v]\nactual: [%v]", + len(expectedOwnContributionData), + len(roundState.OwnContribution.Data), + ) + } + + for i := range roundState.OwnContribution.Data { + if roundState.OwnContribution.Data[i] != expectedOwnContributionData[i] { + t.Fatalf( + "unexpected own contribution byte at index [%d]\nexpected: [%x]\nactual: [%x]", + i, + expectedOwnContributionData[i], + roundState.OwnContribution.Data[i], + ) + } + } +} + +func TestDecodeBuildTaggedTBTCSignerStartSignRoundResponse_RejectsZeroSigningParticipant( + t *testing.T, +) { + _, err := decodeBuildTaggedTBTCSignerStartSignRoundResponse( + []byte( + `{"session_id":"session-1","round_id":"round-1","required_contributions":2,"message_digest_hex":"abcd","signing_participants":[1,0,3],"own_contribution":{"identifier":3,"signature_share_hex":"deadbeef"}}`, + ), + ) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerStartSignRoundResponse_RejectsDuplicateSigningParticipant( + t *testing.T, +) { + _, err := decodeBuildTaggedTBTCSignerStartSignRoundResponse( + []byte( + `{"session_id":"session-1","round_id":"round-1","required_contributions":2,"message_digest_hex":"abcd","signing_participants":[1,2,2],"own_contribution":{"identifier":3,"signature_share_hex":"deadbeef"}}`, + ), + ) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerStartSignRoundResponse_RejectsZeroOwnContributionIdentifier( + t *testing.T, +) { + _, err := decodeBuildTaggedTBTCSignerStartSignRoundResponse( + []byte( + `{"session_id":"session-1","round_id":"round-1","required_contributions":2,"message_digest_hex":"abcd","signing_participants":[1,2,3],"own_contribution":{"identifier":0,"signature_share_hex":"deadbeef"}}`, + ), + ) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerFinalizeSignRoundResponse(t *testing.T) { + signature, err := decodeBuildTaggedTBTCSignerFinalizeSignRoundResponse( + []byte(`{"session_id":"session-1","round_id":"round-1","signature_hex":"deadbeef"}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + expectedSignature := []byte{0xde, 0xad, 0xbe, 0xef} + if len(signature) != len(expectedSignature) { + t.Fatalf( + "unexpected signature length\nexpected: [%v]\nactual: [%v]", + len(expectedSignature), + len(signature), + ) + } + + for i := range signature { + if signature[i] != expectedSignature[i] { + t.Fatalf( + "unexpected signature byte at index [%d]\nexpected: [%x]\nactual: [%x]", + i, + expectedSignature[i], + signature[i], + ) + } + } +} + +func TestBuildTaggedTBTCSignerBuildTaprootTxRequestPayload(t *testing.T) { + scriptTreeHex := "deadbeef" + + payload, err := buildTaggedTBTCSignerBuildTaprootTxRequestPayload( + "session-buildtx-1", + []NativeTBTCSignerTxInput{ + { + TxIDHex: strings.Repeat("11", 32), + Vout: 3, + ValueSats: 1000, + }, + }, + []NativeTBTCSignerTxOutput{ + { + ScriptPubKeyHex: "0014deadbeef", + ValueSats: 900, + }, + }, + &scriptTreeHex, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerBuildTaprootTxRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-buildtx-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-buildtx-1", + request.SessionID, + ) + } + + if len(request.Inputs) != 1 { + t.Fatalf( + "unexpected input count\nexpected: [%v]\nactual: [%v]", + 1, + len(request.Inputs), + ) + } + + if request.Inputs[0].TxIDHex != strings.Repeat("11", 32) { + t.Fatalf( + "unexpected input txid\nexpected: [%v]\nactual: [%v]", + strings.Repeat("11", 32), + request.Inputs[0].TxIDHex, + ) + } + + if len(request.Outputs) != 1 { + t.Fatalf( + "unexpected output count\nexpected: [%v]\nactual: [%v]", + 1, + len(request.Outputs), + ) + } + + if request.Outputs[0].ScriptPubKeyHex != "0014deadbeef" { + t.Fatalf( + "unexpected output script pubkey\nexpected: [%v]\nactual: [%v]", + "0014deadbeef", + request.Outputs[0].ScriptPubKeyHex, + ) + } + + if request.ScriptTreeHex == nil || *request.ScriptTreeHex != scriptTreeHex { + t.Fatal("expected script tree hex to be present and preserved") + } +} + +func TestBuildTaggedTBTCSignerBuildTaprootTxRequestPayload_RejectsInvalidInput( + t *testing.T, +) { + scriptTreeHex := "" + + testCases := []struct { + name string + sessionID string + inputs []NativeTBTCSignerTxInput + outputs []NativeTBTCSignerTxOutput + scriptTreeHex *string + }{ + { + name: "empty session id", + sessionID: "", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: strings.Repeat("11", 32), Vout: 0, ValueSats: 1}, + }, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014aa", ValueSats: 1}, + }, + }, + { + name: "empty inputs", + sessionID: "session-1", + inputs: nil, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014aa", ValueSats: 1}, + }, + }, + { + name: "empty outputs", + sessionID: "session-1", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: strings.Repeat("11", 32), Vout: 0, ValueSats: 1}, + }, + outputs: nil, + }, + { + name: "input txid empty", + sessionID: "session-1", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: "", Vout: 0, ValueSats: 1}, + }, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014aa", ValueSats: 1}, + }, + }, + { + name: "output script empty", + sessionID: "session-1", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: strings.Repeat("11", 32), Vout: 0, ValueSats: 1}, + }, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "", ValueSats: 1}, + }, + }, + { + name: "script tree empty string", + sessionID: "session-1", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: strings.Repeat("11", 32), Vout: 0, ValueSats: 1}, + }, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014aa", ValueSats: 1}, + }, + scriptTreeHex: &scriptTreeHex, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := buildTaggedTBTCSignerBuildTaprootTxRequestPayload( + tc.sessionID, + tc.inputs, + tc.outputs, + tc.scriptTreeHex, + ) + if err == nil { + t.Fatal("expected payload build error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerBuildTaprootTxResponse(t *testing.T) { + result, err := decodeBuildTaggedTBTCSignerBuildTaprootTxResponse( + []byte(`{"session_id":"session-buildtx-1","tx_hex":"deadbeef"}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if result.SessionID != "session-buildtx-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-buildtx-1", + result.SessionID, + ) + } + + if result.TxHex != "deadbeef" { + t.Fatalf( + "unexpected tx hex\nexpected: [%v]\nactual: [%v]", + "deadbeef", + result.TxHex, + ) + } +} + +func TestBuildTaggedTBTCSignerVerifySignatureShareRequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerVerifySignatureShareRequestPayload( + "session-1", + []byte{0xde, 0xad}, + []byte{0xbe, 0xef}, + 2, + nil, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerVerifySignatureShareRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + request.SessionID, + ) + } + if request.SigningPackageHex != "dead" { + t.Fatalf( + "unexpected signing package hex\nexpected: [%v]\nactual: [%v]", + "dead", + request.SigningPackageHex, + ) + } + if request.SignatureShareHex != "beef" { + t.Fatalf( + "unexpected signature share hex\nexpected: [%v]\nactual: [%v]", + "beef", + request.SignatureShareHex, + ) + } + if request.MemberIdentifier != 2 { + t.Fatalf( + "unexpected member identifier\nexpected: [%v]\nactual: [%v]", + 2, + request.MemberIdentifier, + ) + } + if request.TaprootMerkleRootHex != nil { + t.Fatalf( + "expected omitted taproot merkle root, got: [%v]", + *request.TaprootMerkleRootHex, + ) + } +} + +func TestBuildTaggedTBTCSignerVerifySignatureShareRequestPayload_TaprootMerkleRoot( + t *testing.T, +) { + var taprootMerkleRoot [32]byte + taprootMerkleRoot[0] = 0xab + taprootMerkleRoot[31] = 0xcd + + payload, err := buildTaggedTBTCSignerVerifySignatureShareRequestPayload( + "session-1", + []byte{0xde, 0xad}, + []byte{0xbe, 0xef}, + 2, + &taprootMerkleRoot, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerVerifySignatureShareRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.TaprootMerkleRootHex == nil { + t.Fatal("expected taproot merkle root") + } + expectedTaprootMerkleRootHex := hex.EncodeToString(taprootMerkleRoot[:]) + if *request.TaprootMerkleRootHex != expectedTaprootMerkleRootHex { + t.Fatalf( + "unexpected taproot merkle root hex\nexpected: [%v]\nactual: [%v]", + expectedTaprootMerkleRootHex, + *request.TaprootMerkleRootHex, + ) + } +} + +func TestBuildTaggedTBTCSignerVerifySignatureShareRequestPayload_EmptySessionID( + t *testing.T, +) { + _, err := buildTaggedTBTCSignerVerifySignatureShareRequestPayload( + "", + []byte{0xde, 0xad}, + []byte{0xbe, 0xef}, + 2, + nil, + ) + if err == nil { + t.Fatal("expected an empty session id to be rejected") + } + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf("expected ErrNativeBridgeOperationFailed, got: [%v]", err) + } +} + +// The verify-share request builder must NOT reject empty/short package or share +// bytes: those are the SUBJECT of the engine's verdict. A member who submits +// empty/garbage inner FROST bytes must reach the engine (which returns +// `invalid` -> blame); a bridge-side rejection would surface as an FFI error the +// Go host maps to ShareIndeterminate, letting the cheater dodge blame. This test +// pins that the builder passes such bytes through as empty/short hex. +func TestBuildTaggedTBTCSignerVerifySignatureShareRequestPayload_PassesThroughEmptyBlameSubjectBytes( + t *testing.T, +) { + payload, err := buildTaggedTBTCSignerVerifySignatureShareRequestPayload( + "session-1", + nil, + nil, + 2, + nil, + ) + if err != nil { + t.Fatalf("empty package/share bytes must pass through, got error: [%v]", err) + } + + var request buildTaggedTBTCSignerVerifySignatureShareRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if request.SigningPackageHex != "" { + t.Fatalf("expected empty signing package hex, got: [%q]", request.SigningPackageHex) + } + if request.SignatureShareHex != "" { + t.Fatalf("expected empty signature share hex, got: [%q]", request.SignatureShareHex) + } +} + +func TestDecodeBuildTaggedTBTCSignerVerifySignatureShareResponse(t *testing.T) { + tests := map[string]struct { + verdict string + expected NativeShareVerificationVerdict + }{ + "valid": {verdict: "valid", expected: NativeShareVerdictValid}, + "invalid": {verdict: "invalid", expected: NativeShareVerdictInvalid}, + "indeterminate": {verdict: "indeterminate", expected: NativeShareVerdictIndeterminate}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + payload := []byte(fmt.Sprintf(`{"verdict":%q}`, test.verdict)) + verdict, err := decodeBuildTaggedTBTCSignerVerifySignatureShareResponse(payload) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + if verdict != test.expected { + t.Fatalf( + "unexpected verdict\nexpected: [%v]\nactual: [%v]", + test.expected, + verdict, + ) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerVerifySignatureShareResponse_UnrecognizedVerdict( + t *testing.T, +) { + verdict, err := decodeBuildTaggedTBTCSignerVerifySignatureShareResponse( + []byte(`{"verdict":"maybe"}`), + ) + if err == nil { + t.Fatal("expected an unrecognized verdict to be rejected") + } + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf("expected ErrNativeBridgeOperationFailed, got: [%v]", err) + } + // An unrecognized verdict must fail closed to the safe Indeterminate, never + // to a blame verdict, even though the caller is expected to check the error. + if verdict != NativeShareVerdictIndeterminate { + t.Fatalf( + "expected Indeterminate on error\nexpected: [%v]\nactual: [%v]", + NativeShareVerdictIndeterminate, + verdict, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerVerifySignatureShareResponse_MalformedJSON( + t *testing.T, +) { + verdict, err := decodeBuildTaggedTBTCSignerVerifySignatureShareResponse( + []byte("not json"), + ) + if err == nil { + t.Fatal("expected malformed JSON to be rejected") + } + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf("expected ErrNativeBridgeOperationFailed, got: [%v]", err) + } + // The other decoder error path (a json.Unmarshal failure) must also fail + // closed to the safe Indeterminate, never to a blame verdict. + if verdict != NativeShareVerdictIndeterminate { + t.Fatalf( + "expected Indeterminate on error\nexpected: [%v]\nactual: [%v]", + NativeShareVerdictIndeterminate, + verdict, + ) + } +} + +func testInteractiveAttemptContext() NativeInteractiveAttemptContext { + return NativeInteractiveAttemptContext{ + AttemptNumber: 3, + CoordinatorIdentifier: 2, + IncludedParticipants: []uint16{1, 2, 3}, + IncludedParticipantsFingerprint: "fingerprint-abc", + AttemptID: "attempt-1", + } +} + +func TestBuildTaggedTBTCSignerInteractiveSessionOpenRequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerInteractiveSessionOpenRequestPayload( + "session-1", + 2, + []byte{0xab, 0xcd}, + "key-group-1", + 2, + nil, + testInteractiveAttemptContext(), + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerInteractiveSessionOpenRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-1" { + t.Fatalf("unexpected session id: [%s]", request.SessionID) + } + if request.MemberIdentifier != 2 { + t.Fatalf("unexpected member id: [%d]", request.MemberIdentifier) + } + if request.MessageHex != "abcd" { + t.Fatalf("unexpected message hex: [%s]", request.MessageHex) + } + if request.KeyGroup != "key-group-1" { + t.Fatalf("unexpected key group: [%s]", request.KeyGroup) + } + if request.Threshold != 2 { + t.Fatalf("unexpected threshold: [%d]", request.Threshold) + } + if request.TaprootMerkleRootHex != nil { + t.Fatalf("expected omitted taproot root, got: [%v]", *request.TaprootMerkleRootHex) + } + // Wire attempt_number is 1-based: the RFC-21 0-based 3 serializes as 4. + if request.AttemptContext.AttemptNumber != 4 || + request.AttemptContext.CoordinatorIdentifier != 2 || + request.AttemptContext.IncludedParticipantsFingerprint != "fingerprint-abc" || + request.AttemptContext.AttemptID != "attempt-1" { + t.Fatalf("unexpected attempt context: [%+v]", request.AttemptContext) + } + if len(request.AttemptContext.IncludedParticipants) != 3 || + request.AttemptContext.IncludedParticipants[0] != 1 || + request.AttemptContext.IncludedParticipants[2] != 3 { + t.Fatalf("unexpected included participants: [%v]", request.AttemptContext.IncludedParticipants) + } +} + +// The RFC-21 0-based first attempt (AttemptNumber 0) must serialize as the +// engine's 1-based wire attempt_number 1 - the engine rejects 0, so passing it +// through unchanged would fail InteractiveSessionOpen before round 1. +func TestBuildTaggedTBTCSignerInteractiveSessionOpenRequestPayload_FirstAttemptIsOneBasedOnWire(t *testing.T) { + ctx := testInteractiveAttemptContext() + ctx.AttemptNumber = 0 // RFC-21 first attempt + + payload, err := buildTaggedTBTCSignerInteractiveSessionOpenRequestPayload( + "session-1", 2, []byte{0xab}, "key-group-1", 2, nil, ctx, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerInteractiveSessionOpenRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if request.AttemptContext.AttemptNumber != 1 { + t.Fatalf( + "expected RFC-21 attempt 0 to serialize as wire attempt_number 1, got [%d]", + request.AttemptContext.AttemptNumber, + ) + } +} + +func TestBuildTaggedTBTCSignerInteractiveSessionOpenRequestPayload_TaprootMerkleRoot(t *testing.T) { + var root [32]byte + root[0] = 0xab + root[31] = 0xcd + + payload, err := buildTaggedTBTCSignerInteractiveSessionOpenRequestPayload( + "session-1", 2, []byte{0xab}, "key-group-1", 2, &root, testInteractiveAttemptContext(), + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerInteractiveSessionOpenRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if request.TaprootMerkleRootHex == nil { + t.Fatal("expected taproot merkle root") + } + if *request.TaprootMerkleRootHex != hex.EncodeToString(root[:]) { + t.Fatalf("unexpected taproot root hex: [%s]", *request.TaprootMerkleRootHex) + } +} + +func TestBuildTaggedTBTCSignerInteractiveSessionOpenRequestPayload_RejectsInvalidInput(t *testing.T) { + ctx := testInteractiveAttemptContext() + emptyCtx := NativeInteractiveAttemptContext{} + noParticipantsCtx := testInteractiveAttemptContext() + noParticipantsCtx.IncludedParticipants = nil + + tests := map[string]struct { + sessionID string + member uint16 + message []byte + keyGroup string + threshold uint16 + ctx NativeInteractiveAttemptContext + }{ + "empty session": {"", 2, []byte{0xab}, "kg", 2, ctx}, + "zero member": {"s", 0, []byte{0xab}, "kg", 2, ctx}, + "empty message": {"s", 2, nil, "kg", 2, ctx}, + "empty key group": {"s", 2, []byte{0xab}, "", 2, ctx}, + "zero threshold": {"s", 2, []byte{0xab}, "kg", 0, ctx}, + "empty attempt context": {"s", 2, []byte{0xab}, "kg", 2, emptyCtx}, + "no included participants": {"s", 2, []byte{0xab}, "kg", 2, noParticipantsCtx}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := buildTaggedTBTCSignerInteractiveSessionOpenRequestPayload( + test.sessionID, test.member, test.message, test.keyGroup, test.threshold, nil, test.ctx, + ) + if err == nil { + t.Fatal("expected invalid input to be rejected") + } + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf("expected ErrNativeBridgeOperationFailed, got: [%v]", err) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerInteractiveSessionOpenResponse(t *testing.T) { + result, err := decodeBuildTaggedTBTCSignerInteractiveSessionOpenResponse( + []byte(`{"session_id":"session-1","attempt_id":"attempt-1","idempotent":true}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + if result.SessionID != "session-1" || result.AttemptID != "attempt-1" || !result.Idempotent { + t.Fatalf("unexpected result: [%+v]", result) + } +} + +func TestBuildTaggedTBTCSignerInteractiveRound1RequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerInteractiveRound1RequestPayload("session-1", "attempt-1", 2) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + var request buildTaggedTBTCSignerInteractiveRound1Request + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if request.SessionID != "session-1" || request.AttemptID != "attempt-1" || request.MemberIdentifier != 2 { + t.Fatalf("unexpected request: [%+v]", request) + } + + if _, err := buildTaggedTBTCSignerInteractiveRound1RequestPayload("", "attempt-1", 2); err == nil { + t.Fatal("expected empty session id to be rejected") + } +} + +func TestDecodeBuildTaggedTBTCSignerInteractiveRound1Response(t *testing.T) { + commitments, err := decodeBuildTaggedTBTCSignerInteractiveRound1Response( + []byte(`{"commitments_hex":"dead"}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + if hex.EncodeToString(commitments) != "dead" { + t.Fatalf("unexpected commitments: [%x]", commitments) + } +} + +func TestBuildTaggedTBTCSignerInteractiveRound2RequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerInteractiveRound2RequestPayload( + "session-1", "attempt-1", 2, []byte{0xbe, 0xef}, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + var request buildTaggedTBTCSignerInteractiveRound2Request + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if request.SigningPackageHex != "beef" { + t.Fatalf("unexpected signing package hex: [%s]", request.SigningPackageHex) + } + + if _, err := buildTaggedTBTCSignerInteractiveRound2RequestPayload("session-1", "attempt-1", 2, nil); err == nil { + t.Fatal("expected empty signing package to be rejected") + } +} + +func TestDecodeBuildTaggedTBTCSignerInteractiveRound2Response(t *testing.T) { + share, err := decodeBuildTaggedTBTCSignerInteractiveRound2Response( + []byte(`{"session_id":"s","attempt_id":"a","signature_share_hex":"cafe"}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + if hex.EncodeToString(share) != "cafe" { + t.Fatalf("unexpected signature share: [%x]", share) + } +} + +func TestBuildTaggedTBTCSignerInteractiveSessionAbortRequestPayload(t *testing.T) { + attemptID := "attempt-1" + payload, err := buildTaggedTBTCSignerInteractiveSessionAbortRequestPayload("session-1", &attemptID) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + var request buildTaggedTBTCSignerInteractiveSessionAbortRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if request.AttemptID == nil || *request.AttemptID != "attempt-1" { + t.Fatalf("unexpected attempt id: [%v]", request.AttemptID) + } + + // A nil attempt id (abort whatever is live) is valid and omitted on the wire. + payloadNil, err := buildTaggedTBTCSignerInteractiveSessionAbortRequestPayload("session-1", nil) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + var requestNil buildTaggedTBTCSignerInteractiveSessionAbortRequest + if err := json.Unmarshal(payloadNil, &requestNil); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if requestNil.AttemptID != nil { + t.Fatalf("expected omitted attempt id, got: [%v]", *requestNil.AttemptID) + } + + if _, err := buildTaggedTBTCSignerInteractiveSessionAbortRequestPayload("", nil); err == nil { + t.Fatal("expected empty session id to be rejected") + } +} + +func TestDecodeBuildTaggedTBTCSignerInteractiveSessionAbortResponse(t *testing.T) { + result, err := decodeBuildTaggedTBTCSignerInteractiveSessionAbortResponse( + []byte(`{"session_id":"session-1","aborted":true}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + if result.SessionID != "session-1" || !result.Aborted { + t.Fatalf("unexpected result: [%+v]", result) + } +} + +// Every interactive decoder must reject a malformed payload rather than return a +// zero-valued result, and the open decoder must also reject a structurally +// valid response missing the session/attempt ids. +func TestDecodeBuildTaggedTBTCSignerInteractiveResponses_RejectMalformed(t *testing.T) { + malformed := []byte("not json") + + if _, err := decodeBuildTaggedTBTCSignerInteractiveSessionOpenResponse(malformed); err == nil { + t.Fatal("open: expected malformed JSON to be rejected") + } + if _, err := decodeBuildTaggedTBTCSignerInteractiveRound1Response(malformed); err == nil { + t.Fatal("round1: expected malformed JSON to be rejected") + } + if _, err := decodeBuildTaggedTBTCSignerInteractiveRound2Response(malformed); err == nil { + t.Fatal("round2: expected malformed JSON to be rejected") + } + if _, err := decodeBuildTaggedTBTCSignerInteractiveSessionAbortResponse(malformed); err == nil { + t.Fatal("abort: expected malformed JSON to be rejected") + } + + if _, err := decodeBuildTaggedTBTCSignerInteractiveSessionOpenResponse([]byte(`{}`)); err == nil { + t.Fatal("open: expected a response missing session/attempt ids to be rejected") + } +} + +func TestBuildTaggedTBTCSignerInteractiveAggregateRequestPayload(t *testing.T) { + shares := []nativeFROSTSignatureShare{ + {Identifier: "id-1", Data: []byte{0xaa}}, + {Identifier: "id-2", Data: []byte{0xbb}}, + } + + payload, err := buildTaggedTBTCSignerInteractiveAggregateRequestPayload( + "session-1", "attempt-1", []byte{0xde, 0xad}, shares, nil, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerInteractiveAggregateRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if request.SessionID != "session-1" || request.AttemptID != "attempt-1" { + t.Fatalf("unexpected session/attempt: [%+v]", request) + } + if request.SigningPackageHex != "dead" { + t.Fatalf("unexpected signing package hex: [%s]", request.SigningPackageHex) + } + if len(request.SignatureShares) != 2 || + request.SignatureShares[0].Identifier != "id-1" || + request.SignatureShares[0].DataHex != "aa" || + request.SignatureShares[1].DataHex != "bb" { + t.Fatalf("unexpected signature shares: [%+v]", request.SignatureShares) + } + + rejections := map[string]struct { + sessionID string + attemptID string + pkg []byte + shares []nativeFROSTSignatureShare + }{ + "empty session": {"", "a", []byte{0x1}, shares}, + "empty attempt": {"s", "", []byte{0x1}, shares}, + "empty signing package": {"s", "a", nil, shares}, + "no shares": {"s", "a", []byte{0x1}, nil}, + "share missing data": {"s", "a", []byte{0x1}, []nativeFROSTSignatureShare{{Identifier: "id-1"}}}, + } + for name, r := range rejections { + t.Run(name, func(t *testing.T) { + if _, err := buildTaggedTBTCSignerInteractiveAggregateRequestPayload( + r.sessionID, r.attemptID, r.pkg, r.shares, nil, + ); err == nil { + t.Fatal("expected invalid input to be rejected") + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerInteractiveAggregateResponse(t *testing.T) { + signature, err := decodeBuildTaggedTBTCSignerInteractiveAggregateResponse( + []byte(`{"session_id":"s","attempt_id":"a","signature_hex":"cafe"}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + if hex.EncodeToString(signature) != "cafe" { + t.Fatalf("unexpected signature: [%x]", signature) + } + + if _, err := decodeBuildTaggedTBTCSignerInteractiveAggregateResponse([]byte("not json")); err == nil { + t.Fatal("expected malformed JSON to be rejected") + } +} + +// The aggregate_share_verification_failed error must surface as the typed error +// carrying the candidate culprits (so the Go host can adjudicate envelope-bound +// blame over them), with the session/attempt filled from the caller's request. +func TestInterpretInteractiveAggregateError_ShareVerificationFailure(t *testing.T) { + structured := &buildTaggedTBTCSignerStructuredError{ + Code: interactiveAggregateShareVerificationFailedCode, + Message: "shares failed verification", + CandidateCulprits: []uint16{2, 3}, + } + // Wrap exactly as the bridge call helper does (double %w). + wrapped := fmt.Errorf( + "%w: tbtc-signer bridge operation [InteractiveAggregate] failed: [%w]", + ErrNativeBridgeOperationFailed, + structured, + ) + + err := interpretInteractiveAggregateError("session-1", "attempt-1", wrapped) + + var aggErr *InteractiveAggregateShareVerificationError + if !errors.As(err, &aggErr) { + t.Fatalf("expected InteractiveAggregateShareVerificationError, got: [%v]", err) + } + if aggErr.SessionID != "session-1" || aggErr.AttemptID != "attempt-1" { + t.Fatalf("unexpected session/attempt: [%+v]", aggErr) + } + if len(aggErr.CandidateCulprits) != 2 || + aggErr.CandidateCulprits[0] != 2 || + aggErr.CandidateCulprits[1] != 3 { + t.Fatalf("unexpected candidate culprits: [%v]", aggErr.CandidateCulprits) + } +} + +func TestInterpretInteractiveAggregateError_OtherErrorPassesThrough(t *testing.T) { + structured := &buildTaggedTBTCSignerStructuredError{Code: "some_other_error", Message: "boom"} + wrapped := fmt.Errorf( + "%w: tbtc-signer bridge operation [InteractiveAggregate] failed: [%w]", + ErrNativeBridgeOperationFailed, + structured, + ) + + err := interpretInteractiveAggregateError("s", "a", wrapped) + + var aggErr *InteractiveAggregateShareVerificationError + if errors.As(err, &aggErr) { + t.Fatal("a non-share-verification error must not become the typed culprit error") + } + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf("expected the original wrapped error to pass through, got: [%v]", err) + } +} + +func TestBuildTaggedTBTCSignerErrorPayload_CandidateCulprits(t *testing.T) { + structured := buildTaggedTBTCSignerErrorPayload([]byte( + `{"code":"aggregate_share_verification_failed","message":"x","candidate_culprits":[2,3]}`, + )) + if structured.Code != interactiveAggregateShareVerificationFailedCode { + t.Fatalf("unexpected code: [%s]", structured.Code) + } + if len(structured.CandidateCulprits) != 2 || + structured.CandidateCulprits[0] != 2 || + structured.CandidateCulprits[1] != 3 { + t.Fatalf("unexpected candidate culprits: [%v]", structured.CandidateCulprits) + } + + // A non-culprit error decodes with an empty culprit list. + plain := buildTaggedTBTCSignerErrorPayload([]byte(`{"code":"validation_error","message":"x"}`)) + if len(plain.CandidateCulprits) != 0 { + t.Fatalf("expected no culprits for a non-culprit error, got: [%v]", plain.CandidateCulprits) + } +} + +func TestBuildTaggedTBTCSignerDeriveInteractiveAttemptContextRequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerDeriveInteractiveAttemptContextRequestPayload( + "session-1", + []byte{0xab, 0xcd}, + "key-group-1", + 2, + 3, // RFC-21 0-based attempt + []uint16{1, 2, 3}, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerDeriveInteractiveAttemptContextRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if request.SessionID != "session-1" { + t.Fatalf("unexpected session id: [%s]", request.SessionID) + } + if request.MessageHex != "abcd" { + t.Fatalf("unexpected message hex: [%s]", request.MessageHex) + } + if request.KeyGroup != "key-group-1" { + t.Fatalf("unexpected key group: [%s]", request.KeyGroup) + } + if request.Threshold != 2 { + t.Fatalf("unexpected threshold: [%d]", request.Threshold) + } + // Wire attempt_number is 1-based: the RFC-21 0-based 3 serializes as 4. + if request.AttemptNumber != 4 { + t.Fatalf("unexpected wire attempt number: [%d]", request.AttemptNumber) + } + if len(request.IncludedParticipants) != 3 || + request.IncludedParticipants[0] != 1 || + request.IncludedParticipants[2] != 3 { + t.Fatalf("unexpected included participants: [%v]", request.IncludedParticipants) + } +} + +func TestBuildTaggedTBTCSignerDeriveInteractiveAttemptContextRequestPayload_RejectsInvalidInput(t *testing.T) { + tests := map[string]struct { + sessionID string + message []byte + keyGroup string + threshold uint16 + participants []uint16 + }{ + "empty session": {"", []byte{0x01}, "kg", 2, []uint16{1, 2}}, + "empty message": {"s", nil, "kg", 2, []uint16{1, 2}}, + "empty key group": {"s", []byte{0x01}, "", 2, []uint16{1, 2}}, + "zero threshold": {"s", []byte{0x01}, "kg", 0, []uint16{1, 2}}, + "empty participants": {"s", []byte{0x01}, "kg", 2, nil}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if _, err := buildTaggedTBTCSignerDeriveInteractiveAttemptContextRequestPayload( + tc.sessionID, tc.message, tc.keyGroup, tc.threshold, 0, tc.participants, + ); err == nil { + t.Fatal("expected invalid input to be rejected") + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse(t *testing.T) { + result, err := decodeBuildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse([]byte(`{ + "attempt_context": { + "attempt_number": 4, + "coordinator_identifier": 2, + "included_participants": [1, 2, 3], + "included_participants_fingerprint": "deadbeef", + "attempt_id": "attempt-xyz" + }, + "frost_identifiers": [ + {"participant_identifier": 1, "frost_identifier": "id-1"}, + {"participant_identifier": 2, "frost_identifier": "id-2"}, + {"participant_identifier": 3, "frost_identifier": "id-3"} + ] + }`)) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + // Wire 1-based attempt_number 4 decodes to the RFC-21 0-based 3. + if result.AttemptContext.AttemptNumber != 3 { + t.Fatalf("unexpected attempt number: [%d]", result.AttemptContext.AttemptNumber) + } + if result.AttemptContext.CoordinatorIdentifier != 2 { + t.Fatalf("unexpected coordinator: [%d]", result.AttemptContext.CoordinatorIdentifier) + } + if result.AttemptContext.IncludedParticipantsFingerprint != "deadbeef" { + t.Fatalf("unexpected fingerprint: [%s]", result.AttemptContext.IncludedParticipantsFingerprint) + } + if result.AttemptContext.AttemptID != "attempt-xyz" { + t.Fatalf("unexpected attempt id: [%s]", result.AttemptContext.AttemptID) + } + if len(result.AttemptContext.IncludedParticipants) != 3 || + result.AttemptContext.IncludedParticipants[2] != 3 { + t.Fatalf("unexpected included participants: [%v]", result.AttemptContext.IncludedParticipants) + } + if len(result.FrostIdentifiers) != 3 || + result.FrostIdentifiers[0].ParticipantIdentifier != 1 || + result.FrostIdentifiers[0].FrostIdentifier != "id-1" || + result.FrostIdentifiers[2].FrostIdentifier != "id-3" { + t.Fatalf("unexpected frost identifiers: [%+v]", result.FrostIdentifiers) + } + + // Malformed JSON and a zero (impossible 1-based) wire attempt number reject. + if _, err := decodeBuildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse([]byte(`{`)); err == nil { + t.Fatal("expected malformed payload to be rejected") + } + if _, err := decodeBuildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse( + []byte(`{"attempt_context":{"attempt_number":0,"coordinator_identifier":2,"included_participants":[1,2],"included_participants_fingerprint":"ab","attempt_id":"a"}}`), + ); err == nil { + t.Fatal("expected zero wire attempt number to be rejected") + } + // One identifier per participant is required: 3 participants, 2 identifiers. + if _, err := decodeBuildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse( + []byte(`{"attempt_context":{"attempt_number":4,"coordinator_identifier":2,"included_participants":[1,2,3],"included_participants_fingerprint":"ab","attempt_id":"a"},"frost_identifiers":[{"participant_identifier":1,"frost_identifier":"id-1"},{"participant_identifier":2,"frost_identifier":"id-2"}]}`), + ); err == nil { + t.Fatal("expected frost-identifier/participant count mismatch to be rejected") + } + // A matching count but mismatched participant correspondence is rejected + // (participant 3 appears at the position participant 2 is expected). + if _, err := decodeBuildTaggedTBTCSignerDeriveInteractiveAttemptContextResponse( + []byte(`{"attempt_context":{"attempt_number":4,"coordinator_identifier":2,"included_participants":[1,2,3],"included_participants_fingerprint":"ab","attempt_id":"a"},"frost_identifiers":[{"participant_identifier":1,"frost_identifier":"id-1"},{"participant_identifier":3,"frost_identifier":"id-3"},{"participant_identifier":2,"frost_identifier":"id-2"}]}`), + ); err == nil { + t.Fatal("expected mismatched participant correspondence to be rejected") + } +} diff --git a/pkg/frost/signing/native_frost_engine_uniffi_registration_frost_native_default.go b/pkg/frost/signing/native_frost_engine_uniffi_registration_frost_native_default.go new file mode 100644 index 0000000000..f6156db084 --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_uniffi_registration_frost_native_default.go @@ -0,0 +1,7 @@ +//go:build frost_native && !(frost_tbtc_signer && cgo) + +package signing + +func registerBuildTaggedNativeFROSTSigningEngine() error { + return nil +} diff --git a/pkg/frost/signing/native_init_config_demand.go b/pkg/frost/signing/native_init_config_demand.go new file mode 100644 index 0000000000..7a275486e0 --- /dev/null +++ b/pkg/frost/signing/native_init_config_demand.go @@ -0,0 +1,100 @@ +package signing + +import ( + "os" + "strings" +) + +// fatalNativeRegistrationExit terminates the process after logging the +// formatted message at fatal level. It is a package-level variable only so +// the demand-enforcement tests can observe the abort instead of dying with +// the test binary; production code must never override it. +var fatalNativeRegistrationExit = func(format string, args ...interface{}) { + registrationLogger.Fatalf(format, args...) +} + +// enforceNativeInitConfigDemand terminates the process when the operator +// demanded config-mode FROST operation (TBTC_SIGNER_INIT_CONFIG_PATH set) +// and this binary did not bring up the FROST-native engine. With the path +// unset this is a no-op and the registration layer keeps its safe-by-default +// posture: failures degrade to the legacy bridge with a warning. +// +// Decision (2026-06-12, recorded in the Phase 5 gates-doc Decision Log): +// setting the path is an explicit demand, so ANY state in which the demand +// is unmet is process-fatal, in every profile and environment, covering the +// whole failure family: +// +// - the config-install leg failed (unreadable file, parse/validation +// rejection, init-time policy or attestation-gate failure, or a loaded +// signer library that predates frost_tbtc_init_signer_config), +// - the engine-registration leg failed after a successful install, +// - the binary cannot honor the demand at all (built without the +// frost_native build tag, so no native registration ever runs). +// +// Fatality is deliberately NOT conditional on the configured profile: an +// unreadable config file cannot reveal its profile, and the signer treats a +// missing profile as production (production-by-omission), so the only +// non-circular rule is to enforce whenever the path is set. Uniform +// semantics also mean testnet rehearses exactly the behavior production +// will have. +// +// The checks are positive (registered state), not merely error-presence: +// LastNativeRegistrationError is reset by later registration legs, so the +// absence of a recorded error does not prove the engine came up. +func enforceNativeInitConfigDemand() { + configPath := strings.TrimSpace(os.Getenv(TBTCSignerInitConfigPathEnv)) + if configPath == "" { + return + } + + if err := LastNativeRegistrationError(); err != nil { + fatalNativeRegistrationExit( + "%s is set [%s]: config-mode FROST operation is demanded but "+ + "native registration failed: [%v]; terminating instead of "+ + "continuing on the legacy bridge (unset %s to run in the "+ + "transitional env-fallback mode)", + TBTCSignerInitConfigPathEnv, + configPath, + err, + TBTCSignerInitConfigPathEnv, + ) + return + } + + executionBackendMutex.RLock() + adapterRegistered := nativeExecutionAdapter != nil + executorRegistered := nativeExecutionFFIExecutor != nil + executionBackendMutex.RUnlock() + + if adapterRegistered && executorRegistered { + return + } + + if !buildHasNativeFROSTRegistration { + fatalNativeRegistrationExit( + "%s is set [%s]: config-mode FROST operation is demanded but "+ + "this binary was built without the frost_native build tag "+ + "and cannot honor it; terminating (deploy a frost_native "+ + "binary, or unset %s to run this one)", + TBTCSignerInitConfigPathEnv, + configPath, + TBTCSignerInitConfigPathEnv, + ) + return + } + + // No recorded error here can mean an earlier leg's failure was + // overwritten by a later leg's success, so point at the warnings + // emitted at failure time instead of claiming a cause. + fatalNativeRegistrationExit( + "%s is set [%s]: config-mode FROST operation is demanded but "+ + "native registration did not complete (native adapter "+ + "registered [%v], native FFI executor registered [%v]); "+ + "terminating instead of continuing on the legacy bridge - "+ + "check the registration warnings logged above for the cause", + TBTCSignerInitConfigPathEnv, + configPath, + adapterRegistered, + executorRegistered, + ) +} diff --git a/pkg/frost/signing/native_init_config_demand_flavor_default.go b/pkg/frost/signing/native_init_config_demand_flavor_default.go new file mode 100644 index 0000000000..17bb2be1c6 --- /dev/null +++ b/pkg/frost/signing/native_init_config_demand_flavor_default.go @@ -0,0 +1,8 @@ +//go:build !frost_native + +package signing + +// buildHasNativeFROSTRegistration reports whether this build flavor runs +// native FROST registration at init time. Used only to pick the precise +// fatal message when an init-config demand cannot be honored. +const buildHasNativeFROSTRegistration = false diff --git a/pkg/frost/signing/native_init_config_demand_flavor_frost_native.go b/pkg/frost/signing/native_init_config_demand_flavor_frost_native.go new file mode 100644 index 0000000000..20b57da5d1 --- /dev/null +++ b/pkg/frost/signing/native_init_config_demand_flavor_frost_native.go @@ -0,0 +1,8 @@ +//go:build frost_native + +package signing + +// buildHasNativeFROSTRegistration reports whether this build flavor runs +// native FROST registration at init time. Used only to pick the precise +// fatal message when an init-config demand cannot be honored. +const buildHasNativeFROSTRegistration = true diff --git a/pkg/frost/signing/native_init_config_demand_test.go b/pkg/frost/signing/native_init_config_demand_test.go new file mode 100644 index 0000000000..47f42ebb1a --- /dev/null +++ b/pkg/frost/signing/native_init_config_demand_test.go @@ -0,0 +1,266 @@ +package signing + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +// Tests in this file mutate process-global registration state and the fatal +// seam; per the package convention they must not use t.Parallel. + +type capturedFatalCalls struct { + messages []string +} + +// captureFatalNativeRegistrationExit swaps the fatal seam for a recorder so +// the demand enforcement can be observed without killing the test binary. +func captureFatalNativeRegistrationExit(t *testing.T) *capturedFatalCalls { + t.Helper() + + capture := &capturedFatalCalls{} + previous := fatalNativeRegistrationExit + fatalNativeRegistrationExit = func(format string, args ...interface{}) { + capture.messages = append(capture.messages, fmt.Sprintf(format, args...)) + } + t.Cleanup(func() { + fatalNativeRegistrationExit = previous + }) + + return capture +} + +// resetNativeRegistrationStateForDemandTest snapshots the process-global +// registration state, clears it for the test, and restores it on cleanup. +func resetNativeRegistrationStateForDemandTest(t *testing.T) { + t.Helper() + + executionBackendMutex.Lock() + previousAdapter := nativeExecutionAdapter + previousExecutor := nativeExecutionFFIExecutor + nativeExecutionAdapter = nil + nativeExecutionFFIExecutor = nil + executionBackendMutex.Unlock() + + registrationErrorMu.Lock() + previousError := lastRegistrationError + lastRegistrationError = nil + registrationErrorMu.Unlock() + + t.Cleanup(func() { + executionBackendMutex.Lock() + nativeExecutionAdapter = previousAdapter + nativeExecutionFFIExecutor = previousExecutor + executionBackendMutex.Unlock() + + setLastRegistrationError(previousError) + }) +} + +type demandTestExecutionStub struct{} + +func (dtes *demandTestExecutionStub) Execute( + _ context.Context, + _ log.StandardLogger, + _ *Request, +) (*Result, error) { + return nil, errors.New("demand test stub is not executable") +} + +func (dtes *demandTestExecutionStub) RegisterUnmarshallers( + _ net.BroadcastChannel, +) { +} + +func registerDemandTestNativeState(t *testing.T) { + t.Helper() + + if err := RegisterNativeExecutionAdapter(&demandTestExecutionStub{}); err != nil { + t.Fatalf("failed to register stub native adapter: [%v]", err) + } + if err := RegisterNativeExecutionFFIExecutor(&demandTestExecutionStub{}); err != nil { + t.Fatalf("failed to register stub FFI executor: [%v]", err) + } +} + +func TestEnforceNativeInitConfigDemand_PathUnset_KeepsDegradePosture(t *testing.T) { + resetNativeRegistrationStateForDemandTest(t) + capture := captureFatalNativeRegistrationExit(t) + t.Setenv(TBTCSignerInitConfigPathEnv, "") + + // Even with a recorded registration failure and nothing registered, an + // unset path means env-fallback mode: registration failures degrade to + // the legacy bridge and never abort the process. + setLastRegistrationError(errors.New("simulated registration failure")) + + enforceNativeInitConfigDemand() + + if len(capture.messages) != 0 { + t.Fatalf( + "expected no fatal exit with the path unset, got: %q", + capture.messages, + ) + } +} + +func TestEnforceNativeInitConfigDemand_PathWhitespace_KeepsDegradePosture(t *testing.T) { + resetNativeRegistrationStateForDemandTest(t) + capture := captureFatalNativeRegistrationExit(t) + t.Setenv(TBTCSignerInitConfigPathEnv, " ") + + setLastRegistrationError(errors.New("simulated registration failure")) + + enforceNativeInitConfigDemand() + + if len(capture.messages) != 0 { + t.Fatalf( + "expected no fatal exit with a whitespace-only path, got: %q", + capture.messages, + ) + } +} + +func TestEnforceNativeInitConfigDemand_RegistrationError_IsFatal(t *testing.T) { + resetNativeRegistrationStateForDemandTest(t) + capture := captureFatalNativeRegistrationExit(t) + t.Setenv(TBTCSignerInitConfigPathEnv, "/etc/keep/tbtc-signer-config.json") + + setLastRegistrationError(errors.New("simulated install rejection")) + + enforceNativeInitConfigDemand() + + if len(capture.messages) != 1 { + t.Fatalf( + "expected exactly one fatal exit, got %d: %q", + len(capture.messages), capture.messages, + ) + } + + message := capture.messages[0] + for _, want := range []string{ + TBTCSignerInitConfigPathEnv, + "/etc/keep/tbtc-signer-config.json", + "simulated install rejection", + } { + if !strings.Contains(message, want) { + t.Errorf("fatal message missing %q: %q", want, message) + } + } +} + +func TestEnforceNativeInitConfigDemand_NothingRegistered_IsFatal(t *testing.T) { + resetNativeRegistrationStateForDemandTest(t) + capture := captureFatalNativeRegistrationExit(t) + t.Setenv(TBTCSignerInitConfigPathEnv, "/etc/keep/tbtc-signer-config.json") + + enforceNativeInitConfigDemand() + + if len(capture.messages) != 1 { + t.Fatalf( + "expected exactly one fatal exit, got %d: %q", + len(capture.messages), capture.messages, + ) + } + + message := capture.messages[0] + if !strings.Contains(message, TBTCSignerInitConfigPathEnv) { + t.Errorf( + "fatal message missing %q: %q", + TBTCSignerInitConfigPathEnv, message, + ) + } + + // The message names the precise cause per build flavor: a binary without + // frost_native can never honor the demand; a frost_native binary reports + // which registration leg is missing. + if buildHasNativeFROSTRegistration { + if !strings.Contains(message, "did not complete") { + t.Errorf( + "fatal message missing registration-incomplete cause: %q", + message, + ) + } + } else { + if !strings.Contains(message, "without the frost_native build tag") { + t.Errorf( + "fatal message missing wrong-binary cause: %q", + message, + ) + } + } +} + +func TestEnforceNativeInitConfigDemand_PartialRegistration_IsFatal(t *testing.T) { + resetNativeRegistrationStateForDemandTest(t) + capture := captureFatalNativeRegistrationExit(t) + t.Setenv(TBTCSignerInitConfigPathEnv, "/etc/keep/tbtc-signer-config.json") + + // Only the FFI executor comes up; the native adapter leg is missing. + // The demand requires the complete bring-up, so this is still fatal. + if err := RegisterNativeExecutionFFIExecutor(&demandTestExecutionStub{}); err != nil { + t.Fatalf("failed to register stub FFI executor: [%v]", err) + } + + enforceNativeInitConfigDemand() + + if len(capture.messages) != 1 { + t.Fatalf( + "expected exactly one fatal exit, got %d: %q", + len(capture.messages), capture.messages, + ) + } +} + +func TestEnforceNativeInitConfigDemand_FullyRegistered_NoFatal(t *testing.T) { + resetNativeRegistrationStateForDemandTest(t) + capture := captureFatalNativeRegistrationExit(t) + t.Setenv(TBTCSignerInitConfigPathEnv, "/etc/keep/tbtc-signer-config.json") + + registerDemandTestNativeState(t) + + enforceNativeInitConfigDemand() + + if len(capture.messages) != 0 { + t.Fatalf( + "expected no fatal exit with the native engine fully registered, "+ + "got: %q", + capture.messages, + ) + } +} + +func TestRegisterNativeExecutionAdapterForBuild_EnforcesInitConfigDemand(t *testing.T) { + resetNativeRegistrationStateForDemandTest(t) + capture := captureFatalNativeRegistrationExit(t) + + // A nonexistent config file: on frost_native builds the install leg + // fails and records a registration error; on default builds no native + // registration runs at all. Both states must be fatal under a set path, + // pinning that the enforcement is wired into the registration entry + // point for every build flavor. + t.Setenv( + TBTCSignerInitConfigPathEnv, + t.TempDir()+"/nonexistent-tbtc-signer-config.json", + ) + + RegisterNativeExecutionAdapterForBuild() + + if len(capture.messages) != 1 { + t.Fatalf( + "expected exactly one fatal exit, got %d: %q", + len(capture.messages), capture.messages, + ) + } + + if !strings.Contains(capture.messages[0], TBTCSignerInitConfigPathEnv) { + t.Errorf( + "fatal message missing %q: %q", + TBTCSignerInitConfigPathEnv, capture.messages[0], + ) + } +} diff --git a/pkg/frost/signing/native_signer_engine_availability_default.go b/pkg/frost/signing/native_signer_engine_availability_default.go new file mode 100644 index 0000000000..efc6d80a8a --- /dev/null +++ b/pkg/frost/signing/native_signer_engine_availability_default.go @@ -0,0 +1,10 @@ +//go:build !frost_native + +package signing + +// nativeSignerEngineAvailable is always false on non-frost_native builds: the +// native tbtc-signer FROST engine is not compiled in, so native FROST signing +// is unavailable. +func nativeSignerEngineAvailable() bool { + return false +} diff --git a/pkg/frost/signing/native_signer_engine_availability_frost_native_test.go b/pkg/frost/signing/native_signer_engine_availability_frost_native_test.go new file mode 100644 index 0000000000..c793ca113d --- /dev/null +++ b/pkg/frost/signing/native_signer_engine_availability_frost_native_test.go @@ -0,0 +1,68 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "errors" + "sync" + "testing" +) + +// TestNativeExecutionAvailable verifies that NativeExecutionAvailable requires +// both a registered native signer engine and a passing libfrost_tbtc ABI probe - +// not just the Go wrapper pointer. +// +// The optional-link dev profile compiles this tag set without linking +// libfrost_tbtc (the bridge tolerates it via dlsym). There the ABI probe reports +// unavailable, so the "available" subtest is skipped - native execution is +// genuinely unavailable. The other subtests hold regardless. +func TestNativeExecutionAvailable(t *testing.T) { + t.Cleanup(func() { + UnregisterNativeTBTCSignerEngine() + resetTBTCSignerABIOnceForTest() + }) + + t.Run("no engine registered -> unavailable", func(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + resetTBTCSignerABIOnceForTest() + if NativeExecutionAvailable() { + t.Fatal("expected false with no native signer engine registered") + } + }) + + t.Run("engine registered and linked lib ABI-compatible -> available", func(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + if err := RegisterNativeTBTCSignerEngine(&mockNativeTBTCSignerEngine{}); err != nil { + t.Fatalf("failed to register native signer engine: [%v]", err) + } + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + resetTBTCSignerABIOnceForTest() // probe the real linked lib + if ensureTBTCSignerABICompatible() != nil { + t.Skip("libfrost_tbtc not linked in this build profile; native execution is genuinely unavailable") + } + if !NativeExecutionAvailable() { + t.Fatal("expected true with the engine registered and the linked lib ABI-compatible") + } + }) + + t.Run("engine registered but ABI probe fails -> unavailable", func(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + if err := RegisterNativeTBTCSignerEngine(&mockNativeTBTCSignerEngine{}); err != nil { + t.Fatalf("failed to register native signer engine: [%v]", err) + } + t.Cleanup(func() { + UnregisterNativeTBTCSignerEngine() + resetTBTCSignerABIOnceForTest() + }) + + // Force the ABI probe to report failure regardless of the build profile. + tbtcSignerABIOnce = sync.Once{} + tbtcSignerABIErr = errors.New("forced: libfrost_tbtc ABI probe failed") + tbtcSignerABIOnce.Do(func() {}) // mark the once done without overwriting the forced verdict + + if NativeExecutionAvailable() { + t.Fatal("expected false when the native signer ABI probe fails despite a registered engine") + } + }) +} diff --git a/pkg/frost/signing/native_signer_engine_availability_frost_tbtc_signer.go b/pkg/frost/signing/native_signer_engine_availability_frost_tbtc_signer.go new file mode 100644 index 0000000000..3f4a3385bd --- /dev/null +++ b/pkg/frost/signing/native_signer_engine_availability_frost_tbtc_signer.go @@ -0,0 +1,22 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +// nativeSignerEngineAvailable reports whether real native FROST signing is +// actually usable in this build/runtime: the build-tagged signer engine is +// registered AND the linked libfrost_tbtc responds to the FFI ABI probe. +// +// The Go wrapper (buildTaggedTBTCSignerEngine) is registered by this build even +// when libfrost_tbtc is absent or lacks the required ABI symbol - it only +// discovers that later, via dlsym/ABI preflight, when an operation runs. So the +// wrapper pointer alone (currentNativeTBTCSignerEngine != nil) is not a reliable +// availability signal. ensureTBTCSignerABICompatible runs the same preflight +// probe the wrapper uses at operation time (dlsym of frost_tbtc_abi_version plus +// a version-compatibility check, cached via sync.Once so it is cheap to call); +// it returns nil only for a present, ABI-compatible library. Requiring both +// makes the FROST startup guard fail fast when the signer library is not truly +// loadable, instead of the node starting and failing DKG/signing at runtime. +func nativeSignerEngineAvailable() bool { + return currentNativeTBTCSignerEngine() != nil && + ensureTBTCSignerABICompatible() == nil +} diff --git a/pkg/frost/signing/native_signer_engine_availability_no_signer.go b/pkg/frost/signing/native_signer_engine_availability_no_signer.go new file mode 100644 index 0000000000..5af181da89 --- /dev/null +++ b/pkg/frost/signing/native_signer_engine_availability_no_signer.go @@ -0,0 +1,11 @@ +//go:build frost_native && (!frost_tbtc_signer || !cgo) + +package signing + +// nativeSignerEngineAvailable is false on frost_native builds that are not linked +// with the native tbtc-signer (frost_tbtc_signer && cgo): the signer engine is +// never registered and there is no libfrost_tbtc to probe, so native FROST +// signing is unavailable. +func nativeSignerEngineAvailable() bool { + return false +} diff --git a/pkg/frost/signing/native_signer_material.go b/pkg/frost/signing/native_signer_material.go new file mode 100644 index 0000000000..af7b84e74f --- /dev/null +++ b/pkg/frost/signing/native_signer_material.go @@ -0,0 +1,90 @@ +package signing + +import "fmt" + +const ( + // NativeSignerMaterialFormatFrostUniFFIV1 is the canonical format name for + // serialized signer material expected by UniFFI-based native FROST bridges. + NativeSignerMaterialFormatFrostUniFFIV1 = "frost-uniffi-v1" +) + +// NativeSignerMaterial carries backend-native signer material required by +// native FROST execution paths. +type NativeSignerMaterial struct { + Format string + Payload []byte +} + +func (nsm *NativeSignerMaterial) clone() *NativeSignerMaterial { + if nsm == nil { + return nil + } + + result := &NativeSignerMaterial{ + Format: nsm.Format, + } + + if len(nsm.Payload) > 0 { + result.Payload = append([]byte{}, nsm.Payload...) + } + + return result +} + +func (nsm *NativeSignerMaterial) validate() error { + if nsm == nil { + return fmt.Errorf("native signer material is nil") + } + + if nsm.Format == "" { + return fmt.Errorf("native signer material format is empty") + } + + if len(nsm.Payload) == 0 { + return fmt.Errorf("native signer material payload is empty") + } + + return nil +} + +// NativeSignerMaterial resolves native signer material required by +// FFI-backed native execution. +// +// Supported Request.SignerMaterial forms: +// - *NativeSignerMaterial +// - NativeSignerMaterial +// - []byte (interpreted as NativeSignerMaterialFormatFrostUniFFIV1 payload) +func (r *Request) NativeSignerMaterial() (*NativeSignerMaterial, error) { + if r == nil { + return nil, fmt.Errorf("request is nil") + } + + if r.SignerMaterial == nil { + return nil, fmt.Errorf("native signer material is nil") + } + + var nativeSignerMaterial *NativeSignerMaterial + + switch signerMaterial := r.SignerMaterial.(type) { + case *NativeSignerMaterial: + nativeSignerMaterial = signerMaterial.clone() + case NativeSignerMaterial: + nativeSignerMaterial = signerMaterial.clone() + case []byte: + nativeSignerMaterial = &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: append([]byte{}, signerMaterial...), + } + default: + return nil, fmt.Errorf( + "native signer material has wrong type: [%T]", + r.SignerMaterial, + ) + } + + if err := nativeSignerMaterial.validate(); err != nil { + return nil, err + } + + return nativeSignerMaterial, nil +} diff --git a/pkg/frost/signing/native_signer_material_test.go b/pkg/frost/signing/native_signer_material_test.go new file mode 100644 index 0000000000..c3b92ffd08 --- /dev/null +++ b/pkg/frost/signing/native_signer_material_test.go @@ -0,0 +1,155 @@ +package signing + +import ( + "bytes" + "strings" + "testing" +) + +func TestRequest_NativeSignerMaterial_FromPointer(t *testing.T) { + input := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0x01, 0x02, 0x03}, + } + + request := &Request{ + SignerMaterial: input, + } + + result, err := request.NativeSignerMaterial() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if result == input { + t.Fatal("expected a clone of native signer material") + } + + if result.Format != input.Format { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + input.Format, + result.Format, + ) + } + + if !bytes.Equal(result.Payload, input.Payload) { + t.Fatalf( + "unexpected signer material payload\nexpected: [%x]\nactual: [%x]", + input.Payload, + result.Payload, + ) + } +} + +func TestRequest_NativeSignerMaterial_FromValue(t *testing.T) { + request := &Request{ + SignerMaterial: NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0xaa, 0xbb}, + }, + } + + result, err := request.NativeSignerMaterial() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if result.Format != NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + NativeSignerMaterialFormatFrostUniFFIV1, + result.Format, + ) + } +} + +func TestRequest_NativeSignerMaterial_FromBytesUsesDefaultFormat(t *testing.T) { + request := &Request{ + SignerMaterial: []byte{0x10, 0x20}, + } + + result, err := request.NativeSignerMaterial() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if result.Format != NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + NativeSignerMaterialFormatFrostUniFFIV1, + result.Format, + ) + } +} + +func TestRequest_NativeSignerMaterial_NilRequest(t *testing.T) { + _, err := (*Request)(nil).NativeSignerMaterial() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "request is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } +} + +func TestRequest_NativeSignerMaterial_NilMaterial(t *testing.T) { + _, err := (&Request{}).NativeSignerMaterial() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native signer material is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native signer material is nil", + err, + ) + } +} + +func TestRequest_NativeSignerMaterial_WrongType(t *testing.T) { + request := &Request{ + SignerMaterial: "invalid", + } + + _, err := request.NativeSignerMaterial() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native signer material has wrong type") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native signer material has wrong type", + err, + ) + } +} + +func TestRequest_NativeSignerMaterial_ValidationFailure(t *testing.T) { + request := &Request{ + SignerMaterial: NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{}, + }, + } + + _, err := request.NativeSignerMaterial() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native signer material payload is empty") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native signer material payload is empty", + err, + ) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version.go b/pkg/frost/signing/native_tbtc_signer_abi_version.go new file mode 100644 index 0000000000..6f856730cb --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -0,0 +1,87 @@ +package signing + +import ( + "encoding/json" + "errors" + "fmt" +) + +// The FFI CONTRACT version this bridge requires from libfrost_tbtc (the Rust +// frost_tbtc_abi_version export / api::FrostTbtcAbiVersionResult). The bridge requires +// the lib's abi_major to MATCH exactly - a higher major broke something this bridge +// does not know, a lower major is too old - and the lib's abi_minor to be AT LEAST the +// minor this bridge uses (additive features are backward-compatible; a higher minor is +// fine and its extras are ignored). Bump these in lockstep with the Rust constants, in +// the SAME PR that bumps ci/frost-signer-pin.env to the lib commit that provides them. +const ( + requiredTBTCSignerABIMajor uint32 = 1 + // Minor 1: this build's distributed-DKG path calls + // frost_tbtc_persist_distributed_dkg_key_package, added in signer ABI 1.1. A + // lib reporting 1.0 lacks the symbol, so require >= 1 to fail closed at the + // ABI preflight rather than mid-DKG at the dlsym. + requiredTBTCSignerABIMinMinor uint32 = 1 +) + +// ErrTBTCSignerABIIncompatible marks a linked libfrost_tbtc whose FFI contract version +// is incompatible with this build. It is fatal: the engine fails closed rather than +// risk a silently misinterpreted struct/JSON contract. +var ErrTBTCSignerABIIncompatible = errors.New( + "linked libfrost_tbtc FFI contract version is incompatible with this build", +) + +// parseTBTCSignerABIVersion decodes the frozen {abi_major, abi_minor} root compatibility +// surface from the lib's frost_tbtc_abi_version response and rejects anything malformed +// as incompatible. BOTH fields are required: pointer fields distinguish "absent" from a +// legitimate zero, because Go's json.Unmarshal silently zero-fills a missing field - and +// a missing abi_minor would otherwise default to 0 and pass the (>= 0) rule, letting a +// partial/broken lib bypass the fail-closed guard. Extra/unknown fields are tolerated by +// design (an additive minor bump may add fields old bridges ignore). Pure (no cgo), so +// it is unit-tested in the default build. +func parseTBTCSignerABIVersion(payload []byte) (major, minor uint32, err error) { + var decoded struct { + AbiMajor *uint32 `json:"abi_major"` + AbiMinor *uint32 `json:"abi_minor"` + } + if err := json.Unmarshal(payload, &decoded); err != nil { + return 0, 0, fmt.Errorf( + "%w: malformed FFI contract version response: %v", + ErrTBTCSignerABIIncompatible, err, + ) + } + if decoded.AbiMajor == nil || decoded.AbiMinor == nil { + return 0, 0, fmt.Errorf( + "%w: FFI contract version response is missing abi_major and/or abi_minor", + ErrTBTCSignerABIIncompatible, + ) + } + return *decoded.AbiMajor, *decoded.AbiMinor, nil +} + +// checkTBTCSignerABICompatibility applies the compatibility rule to a lib-reported FFI +// contract version against this build's required version. Pure (no cgo) so the rule is +// unit-tested in the default build. +func checkTBTCSignerABICompatibility(libMajor, libMinor uint32) error { + return checkABIContractCompatibility( + libMajor, libMinor, requiredTBTCSignerABIMajor, requiredTBTCSignerABIMinMinor, + ) +} + +// checkABIContractCompatibility is the rule, parameterized over the required version so +// every branch (wrong major either direction, too-old minor, higher-minor-ok) is +// testable independent of the current constants: lib major must equal the required +// major, and lib minor must be >= the required minimum minor. +func checkABIContractCompatibility(libMajor, libMinor, reqMajor, reqMinMinor uint32) error { + if libMajor != reqMajor { + return fmt.Errorf( + "%w: lib reports abi_major %d, this build requires exactly %d", + ErrTBTCSignerABIIncompatible, libMajor, reqMajor, + ) + } + if libMinor < reqMinMinor { + return fmt.Errorf( + "%w: lib reports abi_minor %d, this build requires at least %d (major %d)", + ErrTBTCSignerABIIncompatible, libMinor, reqMinMinor, reqMajor, + ) + } + return nil +} diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_frost_native.go b/pkg/frost/signing/native_tbtc_signer_abi_version_frost_native.go new file mode 100644 index 0000000000..34df7527ca --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_frost_native.go @@ -0,0 +1,67 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "errors" + "fmt" + "sync" +) + +// assertTBTCSignerABICompatible fetches the linked lib's FFI contract version and +// applies the compatibility rule, failing closed. It distinguishes two cases: +// +// - MISSING frost_tbtc_abi_version symbol (lib predates ABI versioning, or absent): +// keeps ErrNativeCryptographyUnavailable in the chain - explicitly worded, but still +// "unavailable", so the existing skip/require-cgo handling applies (a stale/absent +// lib SKIPS in dev and is FATAL under the require-cgo gate, like any missing newer +// symbol). +// - PRESENT but wrong (malformed response, wrong major, too-old minor): a real, +// responding-but-incompatible lib -> ErrTBTCSignerABIIncompatible, which fails loudly +// ALWAYS (not skippable). A node must not sign through a lib whose contract diverges. +func assertTBTCSignerABICompatible() error { + payload, err := callBuildTaggedTBTCSignerABIVersion() + if err != nil { + if errors.Is(err, ErrNativeCryptographyUnavailable) { + return fmt.Errorf( + "linked libfrost_tbtc is missing frost_tbtc_abi_version; it predates FFI "+ + "contract versioning or is absent (this build requires major %d, minor >= %d): %w", + requiredTBTCSignerABIMajor, requiredTBTCSignerABIMinMinor, err, + ) + } + return fmt.Errorf( + "%w: fetching the lib FFI contract version: %v", + ErrTBTCSignerABIIncompatible, err, + ) + } + + major, minor, err := parseTBTCSignerABIVersion(payload) + if err != nil { + return err + } + + return checkTBTCSignerABICompatibility(major, minor) +} + +var ( + tbtcSignerABIOnce sync.Once + tbtcSignerABIErr error +) + +// ensureTBTCSignerABICompatible runs the ABI preflight ONCE per process and caches the +// verdict; every subsequent engine operation sees the same result. The library is +// process-global and not hot-swapped, so a single check before the first contract- +// sensitive call is sufficient and deterministic (a per-call check would add no safety). +func ensureTBTCSignerABICompatible() error { + tbtcSignerABIOnce.Do(func() { + tbtcSignerABIErr = assertTBTCSignerABICompatible() + }) + return tbtcSignerABIErr +} + +// resetTBTCSignerABIOnceForTest clears the cached preflight verdict so a test can +// re-run it. Test-only. +func resetTBTCSignerABIOnceForTest() { + tbtcSignerABIOnce = sync.Once{} + tbtcSignerABIErr = nil +} diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_frost_native_test.go b/pkg/frost/signing/native_tbtc_signer_abi_version_frost_native_test.go new file mode 100644 index 0000000000..43a59567fe --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_frost_native_test.go @@ -0,0 +1,31 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "errors" + "testing" +) + +// TestTBTCSignerABIPreflight_CompatibleAgainstLinkedLib asserts the linked libfrost_tbtc +// reports an FFI contract version this build accepts. With no/old lib (the abi symbol is +// absent), assertTBTCSignerABICompatible keeps ErrNativeCryptographyUnavailable in the +// chain, so the test skips - matching the rest of the cgo suite. Against a current lib it +// must be compatible; an incompatibility here is a real, fail-loud finding. +func TestTBTCSignerABIPreflight_CompatibleAgainstLinkedLib(t *testing.T) { + resetTBTCSignerABIOnceForTest() + t.Cleanup(resetTBTCSignerABIOnceForTest) + + err := assertTBTCSignerABICompatible() + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Skip("libfrost_tbtc not linked or predates frost_tbtc_abi_version") + } + if err != nil { + t.Fatalf("linked libfrost_tbtc must be ABI-compatible with this build: %v", err) + } + + // ensure caches the same verdict. + if err := ensureTBTCSignerABICompatible(); err != nil { + t.Fatalf("cached preflight verdict diverged: %v", err) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go new file mode 100644 index 0000000000..0eab735acf --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -0,0 +1,105 @@ +package signing + +import ( + "errors" + "testing" +) + +func TestCheckABIContractCompatibility(t *testing.T) { + // req = major 1, min minor 2, to exercise every branch (the too-old-minor branch is + // unreachable against the real requiredTBTCSignerABIMinMinor of 1). + const reqMajor, reqMinMinor = uint32(1), uint32(2) + tests := []struct { + name string + libMajor uint32 + libMinor uint32 + wantCompatible bool + }{ + {"exact match", 1, 2, true}, + {"higher minor is additive-compatible", 1, 9, true}, + {"minor too old", 1, 1, false}, + {"major too high (lib broke something newer)", 2, 0, false}, + {"major too low (lib too old)", 0, 9, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkABIContractCompatibility(tt.libMajor, tt.libMinor, reqMajor, reqMinMinor) + if tt.wantCompatible && err != nil { + t.Fatalf("expected compatible, got error: %v", err) + } + if !tt.wantCompatible { + if err == nil { + t.Fatal("expected incompatibility error, got nil") + } + if !errors.Is(err, ErrTBTCSignerABIIncompatible) { + t.Fatalf("error must wrap ErrTBTCSignerABIIncompatible: %v", err) + } + } + }) + } +} + +func TestParseTBTCSignerABIVersion(t *testing.T) { + t.Run("valid", func(t *testing.T) { + major, minor, err := parseTBTCSignerABIVersion([]byte(`{"abi_major":2,"abi_minor":3}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if major != 2 || minor != 3 { + t.Fatalf("got (%d,%d), want (2,3)", major, minor) + } + }) + + // Both fields are REQUIRED: a missing field must be rejected, not zero-filled - else + // a partial lib omitting abi_minor would default to 0 and pass the (>= 0) rule. + rejected := map[string]string{ + "missing abi_minor": `{"abi_major":1}`, + "missing abi_major": `{"abi_minor":0}`, + "empty object": `{}`, + "malformed json": `{"abi_major":1,`, + "not an object": `42`, + } + for name, payload := range rejected { + t.Run(name, func(t *testing.T) { + _, _, err := parseTBTCSignerABIVersion([]byte(payload)) + if err == nil { + t.Fatalf("payload %q must be rejected", payload) + } + if !errors.Is(err, ErrTBTCSignerABIIncompatible) { + t.Fatalf("rejection must wrap ErrTBTCSignerABIIncompatible: %v", err) + } + }) + } + + // A present zero minor with major present is VALID (abi 1.0 is a real version). + t.Run("zero minor is valid when present", func(t *testing.T) { + major, minor, err := parseTBTCSignerABIVersion([]byte(`{"abi_major":1,"abi_minor":0}`)) + if err != nil || major != 1 || minor != 0 { + t.Fatalf("got (%d,%d,%v), want (1,0,nil)", major, minor, err) + } + }) + + // Extra/unknown fields are tolerated (additive minor may add fields old bridges ignore). + t.Run("extra fields tolerated", func(t *testing.T) { + major, minor, err := parseTBTCSignerABIVersion([]byte(`{"abi_major":1,"abi_minor":0,"future":"x"}`)) + if err != nil || major != 1 || minor != 0 { + t.Fatalf("got (%d,%d,%v), want (1,0,nil)", major, minor, err) + } + }) +} + +func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { + // Pins the bridge's current required contract (major 1, min minor 0): the matching + // lib version is compatible; a different major is not. A regression here means the + // required constants drifted from what the bridge actually speaks. + if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, requiredTBTCSignerABIMinMinor); err != nil { + t.Fatalf("the required contract version must be self-compatible: %v", err) + } + if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor+1, requiredTBTCSignerABIMinMinor); err == nil { + t.Fatal("a higher major must be incompatible") + } + // Any minor >= the required minimum is accepted (additive). + if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, requiredTBTCSignerABIMinMinor+3); err != nil { + t.Fatalf("a higher minor must be accepted: %v", err) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_build_taproot_tx_frost_native.go b/pkg/frost/signing/native_tbtc_signer_build_taproot_tx_frost_native.go new file mode 100644 index 0000000000..e20207b8e3 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_build_taproot_tx_frost_native.go @@ -0,0 +1,36 @@ +//go:build frost_native + +package signing + +import "fmt" + +// BuildNativeTBTCSignerTaprootTx routes a BuildTaprootTx request through the +// currently-registered coarse tbtc-signer engine. +func BuildNativeTBTCSignerTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + if sessionID == "" { + return nil, fmt.Errorf("session ID is empty") + } + + if len(inputs) == 0 { + return nil, fmt.Errorf("inputs are empty") + } + + if len(outputs) == 0 { + return nil, fmt.Errorf("outputs are empty") + } + + nativeEngine := currentNativeTBTCSignerEngine() + if nativeEngine == nil { + return nil, fmt.Errorf( + "%w: native tbtc-signer engine is unavailable", + ErrNativeCryptographyUnavailable, + ) + } + + return nativeEngine.BuildTaprootTx(sessionID, inputs, outputs, scriptTreeHex) +} diff --git a/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry.go b/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry.go new file mode 100644 index 0000000000..d406baed0c --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry.go @@ -0,0 +1,72 @@ +package signing + +import ( + "fmt" + "sync" +) + +// NativeTBTCSignerCoarseSignatureEvent describes successful coarse-path +// signature production for tbtc-signer payloads. +type NativeTBTCSignerCoarseSignatureEvent struct { + SessionID string + KeyGroupSource string + EngineVersion string +} + +// NativeTBTCSignerCoarseSignatureObserver consumes coarse-signature telemetry +// events. +type NativeTBTCSignerCoarseSignatureObserver func( + event NativeTBTCSignerCoarseSignatureEvent, +) + +var ( + nativeTBTCSignerCoarseSignatureObserverMutex sync.RWMutex + nativeTBTCSignerCoarseSignatureObserver NativeTBTCSignerCoarseSignatureObserver +) + +// RegisterNativeTBTCSignerCoarseSignatureObserver registers a process-wide +// observer used to report tbtc-signer coarse-signature success events. +// Only a single observer is supported. +func RegisterNativeTBTCSignerCoarseSignatureObserver( + observer NativeTBTCSignerCoarseSignatureObserver, +) error { + if observer == nil { + return fmt.Errorf("native tbtc-signer coarse signature observer is nil") + } + + nativeTBTCSignerCoarseSignatureObserverMutex.Lock() + defer nativeTBTCSignerCoarseSignatureObserverMutex.Unlock() + + if nativeTBTCSignerCoarseSignatureObserver != nil { + return fmt.Errorf( + "native tbtc-signer coarse signature observer is already registered", + ) + } + + nativeTBTCSignerCoarseSignatureObserver = observer + + return nil +} + +// UnregisterNativeTBTCSignerCoarseSignatureObserver clears coarse-signature +// observer registration. +func UnregisterNativeTBTCSignerCoarseSignatureObserver() { + nativeTBTCSignerCoarseSignatureObserverMutex.Lock() + defer nativeTBTCSignerCoarseSignatureObserverMutex.Unlock() + + nativeTBTCSignerCoarseSignatureObserver = nil +} + +func emitNativeTBTCSignerCoarseSignatureEvent( + event NativeTBTCSignerCoarseSignatureEvent, +) { + nativeTBTCSignerCoarseSignatureObserverMutex.RLock() + observer := nativeTBTCSignerCoarseSignatureObserver + nativeTBTCSignerCoarseSignatureObserverMutex.RUnlock() + + if observer == nil { + return + } + + observer(event) +} diff --git a/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry_test.go b/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry_test.go new file mode 100644 index 0000000000..5c59d3a020 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry_test.go @@ -0,0 +1,85 @@ +package signing + +import "testing" + +func TestRegisterNativeTBTCSignerCoarseSignatureObserverRejectsNil(t *testing.T) { + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + err := RegisterNativeTBTCSignerCoarseSignatureObserver(nil) + if err == nil { + t.Fatal("expected registration error") + } +} + +func TestRegisterNativeTBTCSignerCoarseSignatureObserverRejectsDuplicate(t *testing.T) { + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + firstErr := RegisterNativeTBTCSignerCoarseSignatureObserver( + func(NativeTBTCSignerCoarseSignatureEvent) {}, + ) + if firstErr != nil { + t.Fatalf("unexpected first registration error: [%v]", firstErr) + } + + secondErr := RegisterNativeTBTCSignerCoarseSignatureObserver( + func(NativeTBTCSignerCoarseSignatureEvent) {}, + ) + if secondErr == nil { + t.Fatal("expected duplicate registration error") + } +} + +func TestEmitNativeTBTCSignerCoarseSignatureEvent(t *testing.T) { + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + var ( + received bool + actual NativeTBTCSignerCoarseSignatureEvent + ) + + err := RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + received = true + actual = event + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + expected := NativeTBTCSignerCoarseSignatureEvent{ + SessionID: "session-1", + KeyGroupSource: "legacy-wallet-pubkey", + EngineVersion: "tbtc-signer/0.1.0-bootstrap", + } + + emitNativeTBTCSignerCoarseSignatureEvent(expected) + + if !received { + t.Fatal("expected coarse signature event to be delivered") + } + + if actual != expected { + t.Fatalf( + "unexpected coarse signature event\nexpected: [%+v]\nactual: [%+v]", + expected, + actual, + ) + } +} + +func TestEmitNativeTBTCSignerCoarseSignatureEventWithoutObserver(t *testing.T) { + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + emitNativeTBTCSignerCoarseSignatureEvent( + NativeTBTCSignerCoarseSignatureEvent{ + SessionID: "session-1", + KeyGroupSource: "legacy-wallet-pubkey", + EngineVersion: "tbtc-signer/0.1.0-bootstrap", + }, + ) +} diff --git a/pkg/frost/signing/native_tbtc_signer_engine_frost_native.go b/pkg/frost/signing/native_tbtc_signer_engine_frost_native.go new file mode 100644 index 0000000000..28203b189e --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_engine_frost_native.go @@ -0,0 +1,215 @@ +//go:build frost_native + +package signing + +import "fmt" + +// NativeTBTCSignerDKGResult captures DKG result metadata returned by RunDKG. +type NativeTBTCSignerDKGResult struct { + SessionID string `json:"sessionID"` + KeyGroup string `json:"keyGroup"` + ParticipantCount uint16 `json:"participantCount"` + Threshold uint16 `json:"threshold"` + CreatedAtUnix uint64 `json:"createdAtUnix"` +} + +// NativeTBTCSignerRoundContribution is a participant contribution consumed by +// tbtc-signer during signature finalization. +type NativeTBTCSignerRoundContribution struct { + Identifier uint16 `json:"identifier"` + Data []byte `json:"data"` +} + +// NativeTBTCSignerTxInput describes an unsigned transaction input consumed by +// BuildTaprootTx. +type NativeTBTCSignerTxInput struct { + TxIDHex string `json:"txIDHex"` + Vout uint32 `json:"vout"` + ValueSats uint64 `json:"valueSats"` +} + +// NativeTBTCSignerTxOutput describes an unsigned transaction output consumed +// by BuildTaprootTx. +type NativeTBTCSignerTxOutput struct { + ScriptPubKeyHex string `json:"scriptPubKeyHex"` + ValueSats uint64 `json:"valueSats"` +} + +// NativeTBTCSignerTxResult captures unsigned transaction metadata returned by +// BuildTaprootTx. +type NativeTBTCSignerTxResult struct { + SessionID string `json:"sessionID"` + TxHex string `json:"txHex"` +} + +// NativeTBTCSignerRoundState captures coarse session round metadata returned by +// StartSignRound. +type NativeTBTCSignerRoundState struct { + SessionID string `json:"sessionID"` + RoundID string `json:"roundID"` + RequiredContributions uint16 `json:"requiredContributions"` + MessageDigestHex string `json:"messageDigestHex"` + SigningParticipants []uint16 `json:"signingParticipants"` + OwnContribution *NativeTBTCSignerRoundContribution `json:"ownContribution"` +} + +// NativeShareVerificationVerdict is the typed result of a single-share FROST +// re-verification (frost_tbtc_verify_signature_share). It mirrors the engine's +// tri-state verdict. +// +// Indeterminate is deliberately the ZERO value: the boundary between blame +// (Invalid) and don't-blame is security-critical, so an unset value, a decode +// failure, or an FFI-transport error all fail closed against false blame. A +// verdict is only meaningful when the accompanying error is nil. +type NativeShareVerificationVerdict int + +const ( + // NativeShareVerdictIndeterminate: verification could not be completed for a + // reason that is not the member's fault (or could not be obtained at all). + // Fail closed against blame. Zero value. + NativeShareVerdictIndeterminate NativeShareVerificationVerdict = iota + // NativeShareVerdictValid: the share is a valid FROST signature share for the + // (tweaked) package. Not blamable. + NativeShareVerdictValid + // NativeShareVerdictInvalid: the share is member-attributable garbage - + // mathematically invalid, or undecodable member-signed bytes. Blamable. + NativeShareVerdictInvalid +) + +// NativeInteractiveAttemptContext is the RFC-21 attempt context an interactive +// signing session is bound to. It mirrors the engine's AttemptContext: the +// orchestrator derives it from the wallet/session state (never from a peer +// message) and passes it on InteractiveSessionOpen. +type NativeInteractiveAttemptContext struct { + // AttemptNumber is the RFC-21 ZERO-based attempt ordinal (attempt 0 is the + // first), matching attempt.AttemptContext. The bridge converts it to the + // engine's ONE-based wire attempt_number (which rejects 0) on the way out, so + // callers pass the natural attempt.AttemptContext value unchanged. + AttemptNumber uint32 + CoordinatorIdentifier uint16 + IncludedParticipants []uint16 + IncludedParticipantsFingerprint string + AttemptID string +} + +// NativeInteractiveSessionOpenResult is the result of InteractiveSessionOpen: +// the engine's canonical attempt id for the opened (or idempotently re-opened) +// attempt. +type NativeInteractiveSessionOpenResult struct { + SessionID string + AttemptID string + Idempotent bool +} + +// NativeInteractiveSessionAbortResult is the result of InteractiveSessionAbort. +// Aborted is false when there was no live attempt to abort. +type NativeInteractiveSessionAbortResult struct { + SessionID string + Aborted bool +} + +// NativeDeriveInteractiveAttemptContextResult is the result of +// DeriveInteractiveAttemptContext: the canonical attempt context the host passes +// to InteractiveSessionOpen (AttemptNumber is the RFC-21 ZERO-based ordinal, +// converted back from the engine's 1-based wire value), plus one FROST +// identifier per included participant in canonical (ascending) order. +type NativeDeriveInteractiveAttemptContextResult struct { + AttemptContext NativeInteractiveAttemptContext + FrostIdentifiers []NativeFROSTParticipantIdentifier +} + +// NativeFROSTParticipantIdentifier pairs a Go member identifier with the +// engine's canonical FROST identifier string (the key-package encoding the +// signing-package and aggregate paths require), so the host never re-implements +// that serialization. +type NativeFROSTParticipantIdentifier struct { + ParticipantIdentifier uint16 + FrostIdentifier string +} + +// NativeTBTCSignerEngine executes coarse, session-keyed tbtc-signer +// operations. +type NativeTBTCSignerEngine interface { + RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + ) (*NativeTBTCSignerDKGResult, error) + StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + taprootMerkleRoot *[32]byte, + ) (*NativeTBTCSignerRoundState, error) + FinalizeSignRound( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, + taprootMerkleRoot *[32]byte, + ) ([]byte, error) + BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, + ) (*NativeTBTCSignerTxResult, error) + VerifySignatureShare( + sessionID string, + signingPackage []byte, + signatureShare []byte, + memberIdentifier uint16, + taprootMerkleRoot *[32]byte, + ) (NativeShareVerificationVerdict, error) +} + +// NativeTBTCSignerSeededDKGEngine is implemented by tbtc-signer engines that +// can pin development dealer DKG to an externally supplied seed. Production +// distributed DKG does not rely on this helper. +type NativeTBTCSignerSeededDKGEngine interface { + RunDKGWithSeed( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + dkgSeedHex string, + ) (*NativeTBTCSignerDKGResult, error) +} + +var nativeTBTCSignerEngine NativeTBTCSignerEngine + +// RegisterNativeTBTCSignerEngine registers the coarse tbtc-signer engine used +// by frost_tbtc_signer builds. +func RegisterNativeTBTCSignerEngine(engine NativeTBTCSignerEngine) error { + if engine == nil { + return fmt.Errorf("native tbtc-signer engine is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeTBTCSignerEngine = engine + + return nil +} + +// UnregisterNativeTBTCSignerEngine clears coarse tbtc-signer engine +// registration. +func UnregisterNativeTBTCSignerEngine() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeTBTCSignerEngine = nil +} + +// CurrentNativeTBTCSignerEngine returns the registered coarse tbtc-signer +// engine. +func CurrentNativeTBTCSignerEngine() NativeTBTCSignerEngine { + return currentNativeTBTCSignerEngine() +} + +func currentNativeTBTCSignerEngine() NativeTBTCSignerEngine { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeTBTCSignerEngine +} diff --git a/pkg/frost/signing/native_tbtc_signer_engine_frost_native_test.go b/pkg/frost/signing/native_tbtc_signer_engine_frost_native_test.go new file mode 100644 index 0000000000..cd5ca81e61 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_engine_frost_native_test.go @@ -0,0 +1,101 @@ +//go:build frost_native + +package signing + +import ( + "fmt" + "testing" +) + +type mockNativeTBTCSignerEngine struct{} + +func (mntse *mockNativeTBTCSignerEngine) RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*NativeTBTCSignerDKGResult, error) { + return nil, fmt.Errorf("not implemented") +} + +func (mntse *mockNativeTBTCSignerEngine) StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + taprootMerkleRoot *[32]byte, +) (*NativeTBTCSignerRoundState, error) { + _ = memberIdentifier + _ = signingParticipants + _ = taprootMerkleRoot + return nil, fmt.Errorf("not implemented") +} + +func (mntse *mockNativeTBTCSignerEngine) FinalizeSignRound( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + _ = taprootMerkleRoot + return nil, fmt.Errorf("not implemented") +} + +func (mntse *mockNativeTBTCSignerEngine) BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + return nil, fmt.Errorf("not implemented") +} + +func (mntse *mockNativeTBTCSignerEngine) VerifySignatureShare( + sessionID string, + signingPackage []byte, + signatureShare []byte, + memberIdentifier uint16, + taprootMerkleRoot *[32]byte, +) (NativeShareVerificationVerdict, error) { + return NativeShareVerdictIndeterminate, fmt.Errorf("not implemented") +} + +func TestRegisterNativeTBTCSignerEngineRejectsNil(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + err := RegisterNativeTBTCSignerEngine(nil) + if err == nil { + t.Fatal("expected registration error") + } +} + +func TestRegisterNativeTBTCSignerEngine(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + engine := &mockNativeTBTCSignerEngine{} + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + if currentNativeTBTCSignerEngine() != engine { + t.Fatal("expected current native tbtc-signer engine to match registered engine") + } +} + +func TestUnregisterNativeTBTCSignerEngine(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + + err := RegisterNativeTBTCSignerEngine(&mockNativeTBTCSignerEngine{}) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + UnregisterNativeTBTCSignerEngine() + + if currentNativeTBTCSignerEngine() != nil { + t.Fatal("expected native tbtc-signer engine to be nil after unregister") + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_error_frost_native.go b/pkg/frost/signing/native_tbtc_signer_error_frost_native.go new file mode 100644 index 0000000000..b98d3197a8 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_error_frost_native.go @@ -0,0 +1,127 @@ +//go:build frost_native + +package signing + +import ( + "encoding/json" + "errors" + "fmt" +) + +// interactiveAggregateShareVerificationFailedCode is the FFI error `code` the +// engine returns when one or more collected shares failed FROST verification +// during interactive aggregation. The accompanying error payload carries the +// candidate culprits. +const interactiveAggregateShareVerificationFailedCode = "aggregate_share_verification_failed" + +type buildTaggedTBTCSignerErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + // CandidateCulprits is populated only for the + // aggregate_share_verification_failed error: the u16 Go member identifiers + // whose shares failed verification (omitted for every other error). + CandidateCulprits []uint16 `json:"candidate_culprits,omitempty"` +} + +// buildTaggedTBTCSignerStructuredError carries the FFI error envelope's +// structured fields so callers can match on Code via `errors.As` rather than +// substring-matching the rendered error string. Older signer builds may +// return errors without a Code field; this type still wraps them via the +// Message field, and consumers should treat an empty Code as a fall-back +// signal to apply legacy substring matching. +type buildTaggedTBTCSignerStructuredError struct { + Code string + Message string + // CandidateCulprits carries the aggregate_share_verification_failed culprit + // list when present; empty for every other error. + CandidateCulprits []uint16 +} + +func (e *buildTaggedTBTCSignerStructuredError) Error() string { + if e == nil { + return "" + } + if e.Code != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Message) + } + return e.Message +} + +// buildTaggedTBTCSignerErrorPayload decodes the FFI error envelope into a +// structured form so callers can match on the `Code` field via `errors.As` +// rather than rely on substring matching against the rendered error string. +// Decode failures and missing-fields edge cases are surfaced via the +// `Message` field with `Code` left empty so consumers know to fall back to +// legacy matching. +func buildTaggedTBTCSignerErrorPayload(payload []byte) *buildTaggedTBTCSignerStructuredError { + var errorResponse buildTaggedTBTCSignerErrorResponse + if err := json.Unmarshal(payload, &errorResponse); err != nil { + return &buildTaggedTBTCSignerStructuredError{ + Message: fmt.Sprintf( + "cannot decode error payload [%x]: %v", + payload, + err, + ), + } + } + + if errorResponse.Code == "" && errorResponse.Message == "" { + return &buildTaggedTBTCSignerStructuredError{ + Message: fmt.Sprintf("empty error payload: [%s]", string(payload)), + } + } + + return &buildTaggedTBTCSignerStructuredError{ + Code: errorResponse.Code, + Message: errorResponse.Message, + CandidateCulprits: errorResponse.CandidateCulprits, + } +} + +// InteractiveAggregateShareVerificationError is returned by InteractiveAggregate +// when aggregation failed because one or more collected shares did not verify. +// +// CandidateCulprits are the engine's PURE-CRYPTO candidates - the wire (u16) Go +// member identifiers whose FROST shares failed verification against the group's +// own verifying material. They are NOT adjudicated blame: a coordinator that +// aggregated honest shares against a substituted package/root would make those +// honest shares appear here. The Go host performs the envelope-bound blame +// adjudication at an f+1 accuser quorum over these candidates (frozen Phase 7.2b +// spec, section 6); this list is its input, never authoritative on its own. +type InteractiveAggregateShareVerificationError struct { + SessionID string + AttemptID string + CandidateCulprits []uint16 + Message string +} + +func (e *InteractiveAggregateShareVerificationError) Error() string { + return fmt.Sprintf( + "interactive aggregate share verification failed for session %q attempt %q: "+ + "candidate culprits %v: %s", + e.SessionID, + e.AttemptID, + e.CandidateCulprits, + e.Message, + ) +} + +// interpretInteractiveAggregateError maps a failed InteractiveAggregate call to +// a typed InteractiveAggregateShareVerificationError when the engine reported a +// share-verification failure (carrying the candidate culprits), so callers can +// errors.As it and feed the culprits to the envelope-bound blame adjudication. +// Any other error is returned unchanged. sessionID/attemptID are the caller's +// known request values - the error payload does not echo them. +func interpretInteractiveAggregateError(sessionID, attemptID string, err error) error { + var structured *buildTaggedTBTCSignerStructuredError + if errors.As(err, &structured) && + structured.Code == interactiveAggregateShareVerificationFailedCode { + return &InteractiveAggregateShareVerificationError{ + SessionID: sessionID, + AttemptID: attemptID, + CandidateCulprits: structured.CandidateCulprits, + Message: structured.Message, + } + } + return err +} diff --git a/pkg/frost/signing/native_tbtc_signer_fallback_telemetry.go b/pkg/frost/signing/native_tbtc_signer_fallback_telemetry.go new file mode 100644 index 0000000000..82a1469ffa --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_fallback_telemetry.go @@ -0,0 +1,66 @@ +package signing + +import ( + "fmt" + "sync" +) + +// NativeTBTCSignerFallbackEvent describes a single fallback from the +// tbtc-signer coarse path to the legacy signing path. +type NativeTBTCSignerFallbackEvent struct { + SessionID string + Reason string + KeyGroupSource string + LegacyPrivateKeyShareExists bool +} + +// NativeTBTCSignerFallbackObserver consumes fallback telemetry events. +type NativeTBTCSignerFallbackObserver func(event NativeTBTCSignerFallbackEvent) + +var ( + nativeTBTCSignerFallbackObserverMutex sync.RWMutex + nativeTBTCSignerFallbackObserver NativeTBTCSignerFallbackObserver +) + +// RegisterNativeTBTCSignerFallbackObserver registers a process-wide observer +// used to report tbtc-signer fallback events. +// Only a single observer is supported. +func RegisterNativeTBTCSignerFallbackObserver( + observer NativeTBTCSignerFallbackObserver, +) error { + if observer == nil { + return fmt.Errorf("native tbtc-signer fallback observer is nil") + } + + nativeTBTCSignerFallbackObserverMutex.Lock() + defer nativeTBTCSignerFallbackObserverMutex.Unlock() + + if nativeTBTCSignerFallbackObserver != nil { + return fmt.Errorf("native tbtc-signer fallback observer is already registered") + } + + nativeTBTCSignerFallbackObserver = observer + + return nil +} + +// UnregisterNativeTBTCSignerFallbackObserver clears fallback-observer +// registration. +func UnregisterNativeTBTCSignerFallbackObserver() { + nativeTBTCSignerFallbackObserverMutex.Lock() + defer nativeTBTCSignerFallbackObserverMutex.Unlock() + + nativeTBTCSignerFallbackObserver = nil +} + +func emitNativeTBTCSignerFallbackEvent(event NativeTBTCSignerFallbackEvent) { + nativeTBTCSignerFallbackObserverMutex.RLock() + observer := nativeTBTCSignerFallbackObserver + nativeTBTCSignerFallbackObserverMutex.RUnlock() + + if observer == nil { + return + } + + observer(event) +} diff --git a/pkg/frost/signing/native_tbtc_signer_fallback_telemetry_test.go b/pkg/frost/signing/native_tbtc_signer_fallback_telemetry_test.go new file mode 100644 index 0000000000..457b9710d2 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_fallback_telemetry_test.go @@ -0,0 +1,75 @@ +package signing + +import ( + "testing" +) + +func TestRegisterNativeTBTCSignerFallbackObserverRejectsNil(t *testing.T) { + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + err := RegisterNativeTBTCSignerFallbackObserver(nil) + if err == nil { + t.Fatal("expected registration error") + } +} + +func TestRegisterNativeTBTCSignerFallbackObserverRejectsDuplicate(t *testing.T) { + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + firstErr := RegisterNativeTBTCSignerFallbackObserver( + func(NativeTBTCSignerFallbackEvent) {}, + ) + if firstErr != nil { + t.Fatalf("unexpected first registration error: [%v]", firstErr) + } + + secondErr := RegisterNativeTBTCSignerFallbackObserver( + func(NativeTBTCSignerFallbackEvent) {}, + ) + if secondErr == nil { + t.Fatal("expected duplicate registration error") + } +} + +func TestEmitNativeTBTCSignerFallbackEvent(t *testing.T) { + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + var ( + received bool + actual NativeTBTCSignerFallbackEvent + ) + + err := RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + received = true + actual = event + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + expected := NativeTBTCSignerFallbackEvent{ + SessionID: "session-1", + Reason: "fallback reason", + KeyGroupSource: "legacy-wallet-pubkey", + LegacyPrivateKeyShareExists: true, + } + + emitNativeTBTCSignerFallbackEvent(expected) + + if !received { + t.Fatal("expected fallback event to be delivered") + } + + if actual != expected { + t.Fatalf( + "unexpected fallback event\nexpected: [%+v]\nactual: [%+v]", + expected, + actual, + ) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_init_config.go b/pkg/frost/signing/native_tbtc_signer_init_config.go new file mode 100644 index 0000000000..725c2942d3 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_init_config.go @@ -0,0 +1,31 @@ +package signing + +// TBTCSignerInitConfigPathEnv optionally points at a JSON file holding the +// tbtc-signer init-time operational configuration. When set, the +// configuration is installed via frost_tbtc_init_signer_config during native +// FROST engine registration, BEFORE any other signer call; a read, parse, +// validation, or symbol-availability failure fails the registration closed +// and TERMINATES THE PROCESS at the end of registration, in every profile +// and build flavor (see enforceNativeInitConfigDemand for the decision +// record and the full failure family). When unset, the signer falls back to +// reading TBTC_SIGNER_* from the process environment (the transitional +// path), where registration failures degrade to the legacy bridge instead. +// +// The JSON schema is owned by the Rust signer (InitSignerConfigRequest in +// pkg/tbtc/signer/src/api.rs): field names are the lowercased TBTC_SIGNER_* +// suffixes, unknown fields are rejected, and once installed the process +// environment is ignored wholesale for covered knobs. Secrets never ride +// this channel: TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX stays on the dedicated +// env/command key-provider path. The file may carry the state_key_command +// execution spec, so restrict its permissions to the operator account +// (e.g. 0600), as with the signer state path. +const TBTCSignerInitConfigPathEnv = "TBTC_SIGNER_INIT_CONFIG_PATH" + +// NativeTBTCSignerInitConfigResult captures the response of an init-time +// signer-config installation (frost_tbtc_init_signer_config). +type NativeTBTCSignerInitConfigResult struct { + Installed bool `json:"installed"` + Idempotent bool `json:"idempotent"` + ConfigFingerprint string `json:"config_fingerprint"` + ConfiguredKeyCount uint32 `json:"configured_key_count"` +} diff --git a/pkg/frost/signing/native_tbtc_signer_init_config_default.go b/pkg/frost/signing/native_tbtc_signer_init_config_default.go new file mode 100644 index 0000000000..b7a91520e5 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_init_config_default.go @@ -0,0 +1,17 @@ +//go:build !(frost_native && frost_tbtc_signer && cgo) + +package signing + +import "fmt" + +// InstallNativeTBTCSignerConfig is unavailable in builds without the +// tbtc-signer cgo bridge; see the frost_native && frost_tbtc_signer && cgo +// variant for the real implementation and contract. +func InstallNativeTBTCSignerConfig( + _ []byte, +) (*NativeTBTCSignerInitConfigResult, error) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [InitSignerConfig] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} diff --git a/pkg/frost/signing/native_tbtc_signer_init_config_default_test.go b/pkg/frost/signing/native_tbtc_signer_init_config_default_test.go new file mode 100644 index 0000000000..6a13af8e65 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_init_config_default_test.go @@ -0,0 +1,18 @@ +//go:build !(frost_native && frost_tbtc_signer && cgo) + +package signing + +import ( + "errors" + "testing" +) + +func TestInstallNativeTBTCSignerConfig_UnavailableWithoutBridge(t *testing.T) { + result, err := InstallNativeTBTCSignerConfig([]byte(`{}`)) + if result != nil { + t.Fatalf("expected nil result, got %+v", result) + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf("expected ErrNativeCryptographyUnavailable, got %v", err) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_material.go b/pkg/frost/signing/native_tbtc_signer_material.go new file mode 100644 index 0000000000..6e0f458630 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_material.go @@ -0,0 +1,71 @@ +package signing + +import ( + "os" + "strings" +) + +const ( + // NativeSignerMaterialFormatFrostTBTCSignerV1 carries signer material for + // tbtc-signer coarse session APIs. + NativeSignerMaterialFormatFrostTBTCSignerV1 = "frost-tbtc-signer-v1" + // NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey marks scaffold-era + // key-group derivation from the legacy wallet public key. Material built + // with this source is placeholder data, not the output of a real FROST DKG + // run, and is refused by default at signing time. See + // `AcceptScaffoldKeyGroupEnvVar` for the opt-in escape hatch. + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey = "legacy-wallet-pubkey" + // NativeTBTCSignerKeyGroupSourceDKGPersisted marks key-group material + // produced by a FROST wallet DKG and persisted for later signing. + NativeTBTCSignerKeyGroupSourceDKGPersisted = "dkg-persisted" + + // AcceptScaffoldKeyGroupEnvVar is the operator-facing opt-in that allows + // the FROST tbtc-signer FFI path to accept signer material whose + // `KeyGroupSource` is `legacy-wallet-pubkey`. Production deployments must + // not set this; it exists for local dev, CI, and integration rehearsals + // where a real DKG hand-off is not yet wired. + AcceptScaffoldKeyGroupEnvVar = "KEEP_CORE_FROST_TBTC_SIGNER_ACCEPT_SCAFFOLD_KEY_GROUP" +) + +// NativeTBTCSignerMaterialPayload is the signer-material payload schema for +// `frost-tbtc-signer-v1`. +type NativeTBTCSignerMaterialPayload struct { + KeyGroup string `json:"keyGroup"` + TaprootOutputKey string `json:"taprootOutputKey,omitempty"` + KeyGroupSource string `json:"keyGroupSource,omitempty"` + DKGSeedHex string `json:"dkgSeedHex,omitempty"` + DKGParticipants []NativeTBTCSignerDKGParticipant `json:"dkgParticipants,omitempty"` + DKGThreshold uint16 `json:"dkgThreshold,omitempty"` + LegacyPrivateKeyShareHex string `json:"legacyPrivateKeyShareHex,omitempty"` +} + +// NativeTBTCSignerDKGParticipant identifies a DKG participant for coarse +// tbtc-signer RunDKG operation. +type NativeTBTCSignerDKGParticipant struct { + Identifier uint16 `json:"identifier"` + PublicKeyHex string `json:"publicKeyHex"` +} + +// AcceptScaffoldKeyGroupEnabled reports whether the operator has opted into +// accepting scaffold-era (legacy-wallet-pubkey) key-group material. Without +// this, the signer material resolver and the FFI signing primitive both +// refuse legacy material rather than silently signing with placeholder +// cryptographic context. +// +// The env var is parsed identically to the bootstrap-mode flag in +// `pkg/frost/signing/backend.go`: case-insensitive `1`, `true`, `yes`, or +// `on`. Anything else (including missing/empty) is treated as disabled, so +// the safe-by-default behavior is to refuse. +func AcceptScaffoldKeyGroupEnabled() bool { + raw, ok := os.LookupEnv(AcceptScaffoldKeyGroupEnvVar) + if !ok { + return false + } + + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/pkg/frost/signing/request.go b/pkg/frost/signing/request.go new file mode 100644 index 0000000000..b01e706757 --- /dev/null +++ b/pkg/frost/signing/request.go @@ -0,0 +1,74 @@ +package signing + +import ( + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +// Request carries execution input for a FROST signing backend. +type Request struct { + Message *big.Int + SessionID string + // RoastSessionID is the STABLE per-signing ROAST session id (derived from + // message+root+startBlock, WITHOUT the attempt number), used for ROAST + // orchestration, AttemptContext.SessionID, the transition-record registry, + // the selector lookup, and the interactive engine session. SessionID stays + // attempt-specific for the coarse/legacy execution path and its replay + // isolation; this stable id lets cross-attempt ROAST state (the previous + // attempt's transition record) be found by the next attempt's selector. + // Empty when the caller does not drive ROAST orchestration; callers that + // build an AttemptContext fall back to SessionID. + RoastSessionID string + MemberIndex group.MemberIndex + // SignerMaterial carries backend-specific signer material. + // Legacy backend expects *tecdsa.PrivateKeyShare. + SignerMaterial any + // PrivateKeyShare is a deprecated legacy alias kept for backward + // compatibility while migrating to backend-specific signer material. + PrivateKeyShare *tecdsa.PrivateKeyShare + // TaprootMerkleRoot carries the optional BIP-341 script merkle root used + // to tweak a Taproot key-path signature. + TaprootMerkleRoot *[32]byte + GroupSize int + DishonestThreshold int + Channel net.BroadcastChannel + MembershipValidator *group.MembershipValidator + Attempt *Attempt +} + +// LegacyPrivateKeyShare resolves the tECDSA private key share required by the +// transitional legacy execution backend. +// +// It first checks the deprecated Request.PrivateKeyShare field for backward +// compatibility, and then falls back to Request.SignerMaterial. +func (r *Request) LegacyPrivateKeyShare() (*tecdsa.PrivateKeyShare, error) { + if r == nil { + return nil, fmt.Errorf("request is nil") + } + + if r.PrivateKeyShare != nil { + return r.PrivateKeyShare, nil + } + + if r.SignerMaterial == nil { + return nil, fmt.Errorf("legacy private key share is nil") + } + + privateKeyShare, ok := r.SignerMaterial.(*tecdsa.PrivateKeyShare) + if !ok { + return nil, fmt.Errorf( + "legacy signing material has wrong type: [%T]", + r.SignerMaterial, + ) + } + + if privateKeyShare == nil { + return nil, fmt.Errorf("legacy private key share is nil") + } + + return privateKeyShare, nil +} diff --git a/pkg/frost/signing/request_test.go b/pkg/frost/signing/request_test.go new file mode 100644 index 0000000000..388b998e10 --- /dev/null +++ b/pkg/frost/signing/request_test.go @@ -0,0 +1,120 @@ +package signing + +import ( + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestRequest_LegacyPrivateKeyShare_FromDeprecatedField(t *testing.T) { + expected := new(tecdsa.PrivateKeyShare) + + request := &Request{ + PrivateKeyShare: expected, + } + + actual, err := request.LegacyPrivateKeyShare() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actual != expected { + t.Fatalf( + "unexpected private key share\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_FromSignerMaterial(t *testing.T) { + expected := new(tecdsa.PrivateKeyShare) + + request := &Request{ + SignerMaterial: expected, + } + + actual, err := request.LegacyPrivateKeyShare() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actual != expected { + t.Fatalf( + "unexpected private key share\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_NilRequest(t *testing.T) { + _, err := (*Request)(nil).LegacyPrivateKeyShare() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "request is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_NilMaterial(t *testing.T) { + _, err := (&Request{}).LegacyPrivateKeyShare() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "legacy private key share is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "legacy private key share is nil", + err, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_WrongMaterialType(t *testing.T) { + request := &Request{ + SignerMaterial: "invalid", + } + + _, err := request.LegacyPrivateKeyShare() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "legacy signing material has wrong type") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "legacy signing material has wrong type", + err, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_NilTypedMaterial(t *testing.T) { + var typedNil *tecdsa.PrivateKeyShare + + request := &Request{ + SignerMaterial: typedNil, + } + + _, err := request.LegacyPrivateKeyShare() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "legacy private key share is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "legacy private key share is nil", + err, + ) + } +} diff --git a/pkg/frost/signing/result.go b/pkg/frost/signing/result.go new file mode 100644 index 0000000000..bff53d34b4 --- /dev/null +++ b/pkg/frost/signing/result.go @@ -0,0 +1,11 @@ +package signing + +import "github.com/keep-network/keep-core/pkg/frost" + +// Result of the FROST signing protocol. +type Result struct { + // Signature is the BIP-340-style signature produced as result of signing. + Signature *frost.Signature + // Attempt contains execution metadata for the attempt producing Signature. + Attempt *Attempt +} diff --git a/pkg/frost/signing/roast_active_attempt_frost_native.go b/pkg/frost/signing/roast_active_attempt_frost_native.go new file mode 100644 index 0000000000..378c9a87b1 --- /dev/null +++ b/pkg/frost/signing/roast_active_attempt_frost_native.go @@ -0,0 +1,160 @@ +//go:build frost_native + +package signing + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ActiveRoastAttempt is the immutable, single-source-of-truth binding for one +// interactive signing attempt. The runner constructs it once - from the +// session/signer-material registry plus the Coordinator handle - and derives +// every engine, collector, and verifier call from it. +// +// Why a dedicated, validated, immutable binding: the engine resolves the group +// key from the sessionID and tweaks by the taproot root, and it cannot tell a +// valid-but-WRONG session/root from the right one. A mis-bound sessionID, +// attempt-context hash, or root would make the engine verify honest shares +// against the wrong material and produce FALSE blame. So the binding is +// validated once here and then held immutable; the runner never re-derives or +// re-extracts these values from peer messages. +type ActiveRoastAttempt struct { + sessionID string + context attempt.AttemptContext + contextHash [attempt.MessageDigestLength]byte + handle roast.AttemptHandle + electedCoordinator group.MemberIndex + taprootMerkleRoot *[32]byte + dkgGroupPublicKey []byte +} + +// NewActiveRoastAttempt binds an attempt after enforcing the consistency +// assertions that keep blame sound: +// +// - sessionID is non-empty and equals ctx.SessionID - the engine resolves the +// group key from this sessionID, so it must be the session that produced +// ctx (the RFC-21 attempt-context hash folds in the session); +// - the handle was minted for ctx (ctx.Hash() == handle.ContextHash()); +// - the elected coordinator is taken AUTHORITATIVELY from the handle via +// coordinator.SelectedCoordinator, never a caller-supplied value; +// - dkgGroupPublicKey is non-empty. +// +// The taproot root and DKG group public key are copied so the binding cannot be +// mutated through the caller's pointer/slice: NextAttempt must derive every +// attempt's seed from the SAME dkgGroupPublicKey bytes, and the verifier must +// tweak by the SAME root, for the whole signing session. +func NewActiveRoastAttempt( + coordinator roast.Coordinator, + handle roast.AttemptHandle, + ctx attempt.AttemptContext, + sessionID string, + taprootMerkleRoot *[32]byte, + dkgGroupPublicKey []byte, +) (*ActiveRoastAttempt, error) { + if coordinator == nil { + return nil, fmt.Errorf("roast runner: coordinator is nil") + } + if sessionID == "" { + return nil, fmt.Errorf("roast runner: session id is empty") + } + if sessionID != ctx.SessionID { + return nil, fmt.Errorf( + "roast runner: session id %q does not match attempt context session id %q", + sessionID, + ctx.SessionID, + ) + } + if ctx.Hash() != handle.ContextHash() { + return nil, fmt.Errorf( + "roast runner: attempt context hash does not match the handle's bound context", + ) + } + elected, err := coordinator.SelectedCoordinator(handle) + if err != nil { + return nil, fmt.Errorf("roast runner: resolve elected coordinator: %w", err) + } + if len(dkgGroupPublicKey) == 0 { + return nil, fmt.Errorf("roast runner: dkg group public key is empty") + } + // The DKG group public key must be the one ctx was built from, not merely + // non-empty: ctx.AttemptSeed = SHA256(dkgGroupPublicKey || sessionID || + // messageDigest), and this same key later feeds NextAttempt's retry-seed + // derivation. Binding ctx's hash (key A) to retry material from a different + // key B would make subsequent attempts diverge, so re-derive the seed and + // reject a mismatch (sessionID is already asserted == ctx.SessionID above). + if attempt.DeriveAttemptSeed(dkgGroupPublicKey, sessionID, ctx.MessageDigest) != ctx.AttemptSeed { + return nil, fmt.Errorf( + "roast runner: dkg group public key does not match the attempt context (seed mismatch)", + ) + } + + var rootCopy *[32]byte + if taprootMerkleRoot != nil { + root := *taprootMerkleRoot + rootCopy = &root + } + + return &ActiveRoastAttempt{ + sessionID: sessionID, + context: cloneAttemptContext(ctx), + contextHash: ctx.Hash(), + handle: handle, + electedCoordinator: elected, + taprootMerkleRoot: rootCopy, + dkgGroupPublicKey: append([]byte(nil), dkgGroupPublicKey...), + }, nil +} + +// SessionID is the engine DKG session this attempt signs under. +func (a *ActiveRoastAttempt) SessionID() string { return a.sessionID } + +// Context is the RFC-21 attempt context. It returns a clone (its slice fields +// copied) so a caller cannot mutate the binding's participant sets and make +// Context().Hash() drift from the pinned ContextHash(). +func (a *ActiveRoastAttempt) Context() attempt.AttemptContext { + return cloneAttemptContext(a.context) +} + +// cloneAttemptContext returns a copy of ctx with its slice fields +// (IncludedSet, ExcludedSet, TransientlyParked) deep-copied, so the result +// shares no backing arrays with the input. Scalar/array fields copy by value. +func cloneAttemptContext(ctx attempt.AttemptContext) attempt.AttemptContext { + ctx.IncludedSet = append([]group.MemberIndex(nil), ctx.IncludedSet...) + ctx.ExcludedSet = append([]group.MemberIndex(nil), ctx.ExcludedSet...) + ctx.TransientlyParked = append([]group.MemberIndex(nil), ctx.TransientlyParked...) + return ctx +} + +// ContextHash is the attempt-context hash (== Handle().ContextHash()). +func (a *ActiveRoastAttempt) ContextHash() [attempt.MessageDigestLength]byte { + return a.contextHash +} + +// Handle is the Coordinator handle for this attempt. +func (a *ActiveRoastAttempt) Handle() roast.AttemptHandle { return a.handle } + +// ElectedCoordinator is the attempt's elected coordinator, resolved +// authoritatively from the handle at construction. +func (a *ActiveRoastAttempt) ElectedCoordinator() group.MemberIndex { + return a.electedCoordinator +} + +// TaprootMerkleRoot returns a COPY of the bound root (nil for a key-path +// spend), so a caller cannot mutate the immutable binding. +func (a *ActiveRoastAttempt) TaprootMerkleRoot() *[32]byte { + if a.taprootMerkleRoot == nil { + return nil + } + root := *a.taprootMerkleRoot + return &root +} + +// DkgGroupPublicKey returns a COPY of the bound DKG group public key. The same +// bytes feed every NextAttempt seed derivation for this session. +func (a *ActiveRoastAttempt) DkgGroupPublicKey() []byte { + return append([]byte(nil), a.dkgGroupPublicKey...) +} diff --git a/pkg/frost/signing/roast_active_attempt_frost_native_test.go b/pkg/frost/signing/roast_active_attempt_frost_native_test.go new file mode 100644 index 0000000000..3b843cc5f9 --- /dev/null +++ b/pkg/frost/signing/roast_active_attempt_frost_native_test.go @@ -0,0 +1,156 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// testDkgGroupPublicKey is the DKG group public key the test attempt contexts +// are built from; NewActiveRoastAttempt now binds the passed key to ctx via the +// derived seed, so callers must pass this same key. +var testDkgGroupPublicKey = []byte{0x01, 0x02} + +func testActiveAttemptContext(t *testing.T, sessionID string, digest byte) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + sessionID, + "key-group-test", + testDkgGroupPublicKey, + [attempt.MessageDigestLength]byte{digest}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("new attempt context: %v", err) + } + return ctx +} + +func TestNewActiveRoastAttempt_BindsAndValidates(t *testing.T) { + coord := roast.NewInMemoryCoordinator() + ctx := testActiveAttemptContext(t, "session-1", 0x42) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + root := [32]byte{0xaa, 0xbb} + dkgKey := append([]byte(nil), testDkgGroupPublicKey...) + + ara, err := NewActiveRoastAttempt(coord, handle, ctx, "session-1", &root, dkgKey) + if err != nil { + t.Fatalf("unexpected construction error: %v", err) + } + + if ara.SessionID() != "session-1" { + t.Fatalf("unexpected session id: %q", ara.SessionID()) + } + if ara.ContextHash() != ctx.Hash() { + t.Fatal("context hash does not match ctx.Hash()") + } + if ara.Handle() != handle { + t.Fatal("handle not bound") + } + // Elected coordinator is taken authoritatively from the handle. + elected, err := coord.SelectedCoordinator(handle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + if ara.ElectedCoordinator() != elected { + t.Fatalf( + "elected coordinator mismatch: got %d, want %d", + ara.ElectedCoordinator(), elected, + ) + } + if got := ara.TaprootMerkleRoot(); got == nil || *got != root { + t.Fatalf("unexpected taproot root: %v", got) + } + if !bytes.Equal(ara.DkgGroupPublicKey(), dkgKey) { + t.Fatalf("unexpected dkg group public key: %x", ara.DkgGroupPublicKey()) + } +} + +func TestNewActiveRoastAttempt_RejectsInconsistentBinding(t *testing.T) { + coord := roast.NewInMemoryCoordinator() + ctx := testActiveAttemptContext(t, "session-1", 0x42) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + // A handle minted for a DIFFERENT context (same session id, different digest) + // so the session-id check passes but the handle/context-hash check fires. + otherCtx := testActiveAttemptContext(t, "session-1", 0x77) + + tests := map[string]struct { + coord roast.Coordinator + handle roast.AttemptHandle + ctx attempt.AttemptContext + sessionID string + dkgKey []byte + }{ + "nil coordinator": {nil, handle, ctx, "session-1", testDkgGroupPublicKey}, + "empty session id": {coord, handle, ctx, "", testDkgGroupPublicKey}, + "session id mismatch": {coord, handle, ctx, "other-session", testDkgGroupPublicKey}, + "handle / context mismatch": {coord, handle, otherCtx, "session-1", testDkgGroupPublicKey}, + "empty dkg group key": {coord, handle, ctx, "session-1", nil}, + // Non-empty but NOT the key ctx was built from: rejected via the seed check. + "dkg key mismatch": {coord, handle, ctx, "session-1", []byte{0xff, 0xfe}}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if _, err := NewActiveRoastAttempt( + test.coord, test.handle, test.ctx, test.sessionID, nil, test.dkgKey, + ); err == nil { + t.Fatal("expected an inconsistent binding to be rejected") + } + }) + } +} + +// The binding copies the taproot root and DKG group public key, so mutating the +// caller's inputs (or the accessors' returns) cannot change it - NextAttempt +// must derive every attempt's seed from the SAME dkg group public key bytes. +func TestActiveRoastAttempt_ImmutableAfterConstruction(t *testing.T) { + coord := roast.NewInMemoryCoordinator() + ctx := testActiveAttemptContext(t, "session-1", 0x42) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + root := [32]byte{0xaa} + // A copy of the bound key (so mutating it below is safe and it still matches + // ctx for the seed check). + dkgKey := append([]byte(nil), testDkgGroupPublicKey...) + + ara, err := NewActiveRoastAttempt(coord, handle, ctx, "session-1", &root, dkgKey) + if err != nil { + t.Fatalf("construction: %v", err) + } + + // Mutate the caller's inputs after construction. + root[0] = 0xff + dkgKey[0] = 0xff + if got := ara.TaprootMerkleRoot(); got[0] != 0xaa { + t.Fatalf("taproot root not copied from caller: %x", got) + } + if got := ara.DkgGroupPublicKey(); got[0] != testDkgGroupPublicKey[0] { + t.Fatalf("dkg group key not copied from caller: %x", got) + } + + // Mutate the accessors' returns: the binding must be unaffected. + ara.TaprootMerkleRoot()[0] = 0xee + ara.DkgGroupPublicKey()[0] = 0xee + if got := ara.TaprootMerkleRoot(); got[0] != 0xaa { + t.Fatalf("taproot root accessor must return a fresh copy: %x", got) + } + if got := ara.DkgGroupPublicKey(); got[0] != 0x01 { + t.Fatalf("dkg group key accessor must return a fresh copy: %x", got) + } +} diff --git a/pkg/frost/signing/roast_distributed_dkg_libp2p_multiproc_e2e_frost_native_test.go b/pkg/frost/signing/roast_distributed_dkg_libp2p_multiproc_e2e_frost_native_test.go new file mode 100644 index 0000000000..1c6a2a91c8 --- /dev/null +++ b/pkg/frost/signing/roast_distributed_dkg_libp2p_multiproc_e2e_frost_native_test.go @@ -0,0 +1,655 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "strconv" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/firewall" + keepnet "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/net/libp2p" + "github.com/keep-network/keep-core/pkg/operator" +) + +// This file closes the key-CUSTODY gap that the dealer-DKG shape-(B) harness +// (roast_shapeb_libp2p_multiproc_e2e_frost_native_test.go) left open. There, DKG was the +// centralized dev "dealer" call (frost_tbtc_run_dkg) run once and the encrypted key group +// COPIED into every worker, so each worker physically held the whole key group. That is +// the transitional dealer path, which the engine HARD-DISABLES in production +// (enforce_bootstrap_dealer_dkg_disabled_in_production: "production requires distributed +// DKG wiring"). +// +// Here every worker runs the REAL distributed FROST DKG (frost_tbtc_dkg_part1/2/3) over +// real libp2p and ends up holding ONLY ITS OWN key package - no node ever sees the whole +// key group. Then the n nodes threshold-sign the message with the low-level engine path +// (GenerateNoncesAndCommitments/NewSigningPackage/Sign/Aggregate), each aggregating its +// own BIP-340 signature independently. So this is production-shaped end to end: distributed +// keygen + threshold signing, n separate OS processes, real transport, true per-node share +// custody. +// +// ROUND-2 CONFIDENTIALITY (why the encryption below is mandatory, not decorative): FROST +// DKG round-2 packages are per-recipient SECRET shares - package i->j is f_i(j). The bus is +// broadcast-only, and if round-2 packages traveled in clear, any node could collect f_i(j) +// for all i and RECONSTRUCT j's secret share (sum_i f_i(j)), defeating the threshold +// property entirely. So each round-2 package is sealed to its recipient with secp256k1 +// ECDH(sender_op_key, recipient_op_key) + AES-256-GCM before it is broadcast; only the +// intended recipient can open it. (Round-1 packages are public commitments and are +// broadcast in clear, as the protocol intends.) +// +// MECHANISM: same subprocess-helper pattern as shape-(B) - the orchestrator re-execs THIS +// test binary (already linked against libfrost_tbtc) as n workers. There is NO central DKG +// and NO shared state file: the low-level FROST path is stateless, so a worker needs only +// the development profile env, not a persisted signer state. + +const ( + ddkgWorkerEnv = "FROST_DDKG_WORKER" + ddkgConfigEnv = "FROST_DDKG_CONFIG" + ddkgTopic = "frost-distributed-dkg-and-sign" + ddkgSigPrefix = "DDKG_SIGNATURE=" + ddkgErrPrefix = "DDKG_ERROR=" + ddkgSkipPrefix = "DDKG_SKIP=" + ddkgVKeyPrefix = "DDKG_GROUPKEY=" // diagnostic only + + phaseRound1 = "dkg-r1" + phaseRound2 = "dkg-r2" + phaseGroupKey = "dkg-groupkey" + phaseCommit = "sign-commit" + phaseShare = "sign-share" +) + +type ddkgMember struct { + Index int `json:"index"` + OperatorDHex string `json:"operator_d_hex"` + Port int `json:"port"` + Multiaddr string `json:"multiaddr"` +} + +type ddkgConfig struct { + N int `json:"n"` + Threshold int `json:"threshold"` + MessageHex string `json:"message_hex"` + Topic string `json:"topic"` + ReadyDir string `json:"ready_dir"` + Members []ddkgMember `json:"members"` +} + +// ddkgMsg is the single wire type for every phase. Recipient==0 means broadcast-to-all; +// Recipient==j means the payload is a per-recipient bundle (round-2). It implements both +// net.TaggedMarshaler and net.TaggedUnmarshaler. +type ddkgMsg struct { + Phase string `json:"phase"` + Sender int `json:"sender"` + Recipient int `json:"recipient"` + Payload []byte `json:"payload"` +} + +func (m *ddkgMsg) Type() string { return "frost/distributed-dkg-sign/v1" } +func (m *ddkgMsg) Marshal() ([]byte, error) { return json.Marshal(m) } +func (m *ddkgMsg) Unmarshal(b []byte) error { return json.Unmarshal(b, m) } + +func TestRealCgoInteractiveSigning_Libp2pMultiProc_DistributedDKG(t *testing.T) { + if idxStr := os.Getenv(ddkgWorkerEnv); idxStr != "" { + runDdkgWorker(t, idxStr) + return + } + runDdkgOrchestrator(t, 3, 2) +} + +func runDdkgOrchestrator(t *testing.T, n int, threshold uint16) { + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + + // One transport/ECDH operator key per member + a free port + its libp2p peer id, so + // the whole peer table (every worker's bootstrap list) is known before launch. + derivation, err := libp2p.Connect( + ctx, + libp2p.Config{Port: freeTCPPort(t)}, + mustGenOperatorKey(t), + firewall.Disabled, + idleRetransmissionTicker(), + ) + if err != nil { + t.Fatalf("derivation provider: %v", err) + } + + members := make([]ddkgMember, 0, n) + for i := 0; i < n; i++ { + priv, pub, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (member %d): %v", i+1, err) + } + peerID, err := derivation.CreateTransportIdentifier(pub) + if err != nil { + t.Fatalf("peer id (member %d): %v", i+1, err) + } + port := freeTCPPort(t) + members = append(members, ddkgMember{ + Index: i + 1, + OperatorDHex: hex.EncodeToString(priv.D.Bytes()), + Port: port, + Multiaddr: fmt.Sprintf("/ip4/127.0.0.1/tcp/%d/p2p/%s", port, peerID), + }) + } + + message := make([]byte, 32) + for i := range message { + message[i] = 0x42 + } + cfg := ddkgConfig{ + N: n, + Threshold: int(threshold), + MessageHex: hex.EncodeToString(message), + Topic: ddkgTopic, + ReadyDir: t.TempDir() + "/ddkg-ready", + Members: members, + } + if err := os.MkdirAll(cfg.ReadyDir, 0o700); err != nil { + t.Fatalf("create readiness dir: %v", err) + } + cfgBytes, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + configPath := t.TempDir() + "/ddkg-config.json" + if err := os.WriteFile(configPath, cfgBytes, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + type result struct { + index int + output string + err error + } + results := make([]result, n) + var wg sync.WaitGroup + for i := range members { + wg.Add(1) + go func(idx int, m ddkgMember) { + defer wg.Done() + cmd := exec.CommandContext(ctx, os.Args[0], + "-test.run", "^TestRealCgoInteractiveSigning_Libp2pMultiProc_DistributedDKG$", + "-test.v", "-test.timeout=170s", + ) + cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ + ddkgWorkerEnv: strconv.Itoa(m.Index), + ddkgConfigEnv: configPath, + "TBTC_SIGNER_PROFILE": "development", + "TBTC_SIGNER_ENFORCE_PROVENANCE_GATE": "false", + frostSubprocessSkipPrefixEnv: ddkgSkipPrefix, + }) + out, err := cmd.CombinedOutput() + results[idx] = result{index: m.Index, output: string(out), err: err} + }(i, members[i]) + } + wg.Wait() + + var winning string + winners := 0 + for _, r := range results { + if skip := extractPrefixed(r.output, ddkgSkipPrefix); skip != "" { + t.Skipf("member %d skipped: %s", r.index, skip) + } + sig := extractPrefixed(r.output, ddkgSigPrefix) + if sig == "" { + t.Fatalf("member %d produced no signature (err=%v):\n%s", r.index, r.err, indentTail(r.output, 40)) + } + raw, err := hex.DecodeString(sig) + if err != nil || len(raw) != 64 { + t.Fatalf("member %d emitted a bad signature %q (decErr=%v len=%d)", r.index, sig, err, len(raw)) + } + if winning == "" { + winning = sig + } else if sig != winning { + t.Fatalf("member %d produced a different signature than a peer:\n got %s\n want %s", r.index, sig, winning) + } + winners++ + } + if winners != n { + t.Fatalf("expected all %d distributed-DKG nodes to aggregate the signature, got %d", n, winners) + } + t.Logf("distributed-DKG: %d separate-process nodes ran real FROST part1/2/3 over libp2p (each holding ONLY its own share) and threshold-signed to the same BIP-340 signature %s…", n, winning[:16]) +} + +func runDdkgWorker(t *testing.T, idxStr string) { + index, err := strconv.Atoi(idxStr) + if err != nil { + fmt.Printf("%sbad worker index %q: %v\n", ddkgErrPrefix, idxStr, err) + return + } + cfgBytes, err := os.ReadFile(os.Getenv(ddkgConfigEnv)) + if err != nil { + fmt.Printf("%sread config: %v\n", ddkgErrPrefix, err) + return + } + var cfg ddkgConfig + if err := json.Unmarshal(cfgBytes, &cfg); err != nil { + fmt.Printf("%sparse config: %v\n", ddkgErrPrefix, err) + return + } + + var self ddkgMember + peers := make([]string, 0, cfg.N-1) + others := make([]int, 0, cfg.N-1) + pubByIndex := map[int]*operator.PublicKey{} + for _, m := range cfg.Members { + key, err := operatorKeyFromDHex(m.OperatorDHex) + if err != nil { + fmt.Printf("%sreconstruct member %d key: %v\n", ddkgErrPrefix, m.Index, err) + return + } + pubByIndex[m.Index] = &key.PublicKey + if m.Index == index { + self = m + } else { + peers = append(peers, m.Multiaddr) + others = append(others, m.Index) + } + } + if self.Index == 0 { + fmt.Printf("%smember %d not in config\n", ddkgErrPrefix, index) + return + } + selfKey, err := operatorKeyFromDHex(self.OperatorDHex) + if err != nil { + fmt.Printf("%sreconstruct self key: %v\n", ddkgErrPrefix, err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 160*time.Second) + defer cancel() + + provider, err := libp2p.Connect( + ctx, + libp2p.Config{Port: self.Port, Peers: peers, Bootstrap: true}, + selfKey, + firewall.Disabled, + periodicRetransmissionTicker(ctx, 300*time.Millisecond), + ) + if err != nil { + fmt.Printf("%slibp2p connect: %v\n", ddkgErrPrefix, err) + return + } + if err := waitForPeers(ctx, provider, cfg.N-1, 60*time.Second); err != nil { + fmt.Printf("%swait for peers: %v\n", ddkgErrPrefix, err) + return + } + channel, err := provider.BroadcastChannelFor(cfg.Topic) + if err != nil { + fmt.Printf("%sbroadcast channel: %v\n", ddkgErrPrefix, err) + return + } + channel.SetUnmarshaler(func() keepnet.TaggedUnmarshaler { return &ddkgMsg{} }) + // Accept only the known member operator keys (membership = authentication here). + known := map[string]bool{} + for _, pub := range pubByIndex { + known[string(operator.MarshalCompressed(pub))] = true + } + if err := channel.SetFilter(func(pub *operator.PublicKey) bool { + return known[string(operator.MarshalCompressed(pub))] + }); err != nil { + fmt.Printf("%sset filter: %v\n", ddkgErrPrefix, err) + return + } + + collector := newDdkgCollector(index) + channel.Recv(ctx, func(m keepnet.Message) { + msg, ok := m.Payload().(*ddkgMsg) + if !ok || msg.Sender == index { + return + } + collector.put(msg.Phase, msg.Sender, msg.Payload) + }) + + if err := waitForMultiprocReady(ctx, cfg.ReadyDir, "ddkg", index, cfg.N, 30*time.Second); err != nil { + fmt.Printf("%sreadiness barrier: %v\n", ddkgErrPrefix, err) + return + } + // Gossipsub mesh warmup before the first round (the periodic ticker also resends). + select { + case <-time.After(3 * time.Second): + case <-ctx.Done(): + fmt.Printf("%scontext done during warmup\n", ddkgErrPrefix) + return + } + + engine := &buildTaggedTBTCSignerEngine{} + selfID := buildTaggedTBTCSignerTestIdentifier(byte(index)) + + // ---- DKG part 1: broadcast my public round-1 commitment package ---- + part1, err := engine.Part1(selfID, uint16(cfg.N), uint16(cfg.Threshold)) + if err != nil { + if reportFrostSubprocessSkip("distributed DKG part1", err) { + return + } + fmt.Printf("%spart1: %v\n", ddkgErrPrefix, err) + return + } + if err := ddkgSend(ctx, channel, phaseRound1, index, 0, mustJSON(part1.Package)); err != nil { + fmt.Printf("%ssend round1: %v\n", ddkgErrPrefix, err) + return + } + r1Raw, err := collector.collect(ctx, phaseRound1, others, 45*time.Second) + if err != nil { + fmt.Printf("%scollect round1: %v\n", ddkgErrPrefix, err) + return + } + round1Packages := make([]*NativeFROSTDKGRound1Package, 0, len(others)) + for _, idx := range others { + var pkg NativeFROSTDKGRound1Package + if err := json.Unmarshal(r1Raw[idx], &pkg); err != nil { + fmt.Printf("%sdecode round1 from %d: %v\n", ddkgErrPrefix, idx, err) + return + } + round1Packages = append(round1Packages, &pkg) + } + + // ---- DKG part 2: produce per-recipient secret packages, SEAL each to its recipient ---- + part2, err := engine.Part2(part1.SecretPackage, round1Packages) + if err != nil { + if reportFrostSubprocessSkip("distributed DKG part2", err) { + return + } + fmt.Printf("%spart2: %v\n", ddkgErrPrefix, err) + return + } + sealedBundle := map[int][]byte{} // recipient index -> ciphertext(round2 package) + for _, recipient := range others { + recipientID := buildTaggedTBTCSignerTestIdentifier(byte(recipient)) + var pkgForRecipient *NativeFROSTDKGRound2Package + for _, pkg := range part2.Packages { + if pkg.Identifier == recipientID { + pkgForRecipient = pkg + break + } + } + if pkgForRecipient == nil { + fmt.Printf("%smissing round2 package for recipient %d\n", ddkgErrPrefix, recipient) + return + } + sealed, err := sealForPeer(selfKey.D.Bytes(), pubByIndex[recipient], mustJSON(pkgForRecipient)) + if err != nil { + fmt.Printf("%sseal round2 for %d: %v\n", ddkgErrPrefix, recipient, err) + return + } + sealedBundle[recipient] = sealed + } + if err := ddkgSend(ctx, channel, phaseRound2, index, 0, mustJSON(sealedBundle)); err != nil { + fmt.Printf("%ssend round2: %v\n", ddkgErrPrefix, err) + return + } + r2Raw, err := collector.collect(ctx, phaseRound2, others, 45*time.Second) + if err != nil { + fmt.Printf("%scollect round2: %v\n", ddkgErrPrefix, err) + return + } + round2Packages := make([]*NativeFROSTDKGRound2Package, 0, len(others)) + for _, senderIdx := range others { + var bundle map[int][]byte + if err := json.Unmarshal(r2Raw[senderIdx], &bundle); err != nil { + fmt.Printf("%sdecode round2 bundle from %d: %v\n", ddkgErrPrefix, senderIdx, err) + return + } + sealed, ok := bundle[index] + if !ok { + fmt.Printf("%sround2 bundle from %d has nothing for me\n", ddkgErrPrefix, senderIdx) + return + } + plain, err := openFromPeer(selfKey.D.Bytes(), pubByIndex[senderIdx], sealed) + if err != nil { + fmt.Printf("%sopen round2 from %d: %v\n", ddkgErrPrefix, senderIdx, err) + return + } + var pkg NativeFROSTDKGRound2Package + if err := json.Unmarshal(plain, &pkg); err != nil { + fmt.Printf("%sdecode round2 from %d: %v\n", ddkgErrPrefix, senderIdx, err) + return + } + pkg.SenderIdentifier = buildTaggedTBTCSignerTestIdentifier(byte(senderIdx)) + round2Packages = append(round2Packages, &pkg) + } + + // ---- DKG part 3: derive MY key package + the shared group public key ---- + dkgResult, err := engine.Part3(part2.SecretPackage, round1Packages, round2Packages) + if err != nil { + if reportFrostSubprocessSkip("distributed DKG part3", err) { + return + } + fmt.Printf("%spart3: %v\n", ddkgErrPrefix, err) + return + } + myKeyPackage := dkgResult.KeyPackage + groupPublicKey := dkgResult.PublicKeyPackage + + // ---- agreement check: every node must derive the same group verifying key ---- + if err := ddkgSend(ctx, channel, phaseGroupKey, index, 0, []byte(groupPublicKey.VerifyingKey)); err != nil { + fmt.Printf("%ssend groupkey: %v\n", ddkgErrPrefix, err) + return + } + gkRaw, err := collector.collect(ctx, phaseGroupKey, others, 30*time.Second) + if err != nil { + fmt.Printf("%scollect groupkey: %v\n", ddkgErrPrefix, err) + return + } + for _, idx := range others { + if string(gkRaw[idx]) != groupPublicKey.VerifyingKey { + fmt.Printf("%sgroup key disagreement with member %d\n", ddkgErrPrefix, idx) + return + } + } + + // ---- threshold sign (low-level path): commit -> share -> aggregate, all over libp2p ---- + nonces, commitmentID, commitmentData, err := engine.GenerateNoncesAndCommitments( + myKeyPackage.Identifier, myKeyPackage.Data, + ) + if err != nil { + if reportFrostSubprocessSkip("generate nonces and commitments", err) { + return + } + fmt.Printf("%sgenerate nonces: %v\n", ddkgErrPrefix, err) + return + } + if err := ddkgSend(ctx, channel, phaseCommit, index, 0, + mustJSON(nativeFROSTCommitment{Identifier: commitmentID, Data: commitmentData})); err != nil { + fmt.Printf("%ssend commitment: %v\n", ddkgErrPrefix, err) + return + } + commitRaw, err := collector.collect(ctx, phaseCommit, others, 45*time.Second) + if err != nil { + fmt.Printf("%scollect commitments: %v\n", ddkgErrPrefix, err) + return + } + commitments := []nativeFROSTCommitment{{Identifier: commitmentID, Data: commitmentData}} + for _, idx := range others { + var c nativeFROSTCommitment + if err := json.Unmarshal(commitRaw[idx], &c); err != nil { + fmt.Printf("%sdecode commitment from %d: %v\n", ddkgErrPrefix, idx, err) + return + } + commitments = append(commitments, c) + } + // Deterministic order across nodes so every node builds the SAME signing package. + sort.Slice(commitments, func(i, j int) bool { return commitments[i].Identifier < commitments[j].Identifier }) + + message, err := hex.DecodeString(cfg.MessageHex) + if err != nil { + fmt.Printf("%sbad message: %v\n", ddkgErrPrefix, err) + return + } + signingPackage, err := engine.NewSigningPackage(message, commitments) + if err != nil { + if reportFrostSubprocessSkip("new signing package", err) { + return + } + fmt.Printf("%snew signing package: %v\n", ddkgErrPrefix, err) + return + } + shareID, shareData, err := engine.Sign(signingPackage, nonces, myKeyPackage.Identifier, myKeyPackage.Data) + if err != nil { + if reportFrostSubprocessSkip("sign", err) { + return + } + fmt.Printf("%ssign: %v\n", ddkgErrPrefix, err) + return + } + if err := ddkgSend(ctx, channel, phaseShare, index, 0, + mustJSON(nativeFROSTSignatureShare{Identifier: shareID, Data: shareData})); err != nil { + fmt.Printf("%ssend share: %v\n", ddkgErrPrefix, err) + return + } + shareRaw, err := collector.collect(ctx, phaseShare, others, 45*time.Second) + if err != nil { + fmt.Printf("%scollect shares: %v\n", ddkgErrPrefix, err) + return + } + shares := []nativeFROSTSignatureShare{{Identifier: shareID, Data: shareData}} + for _, idx := range others { + var s nativeFROSTSignatureShare + if err := json.Unmarshal(shareRaw[idx], &s); err != nil { + fmt.Printf("%sdecode share from %d: %v\n", ddkgErrPrefix, idx, err) + return + } + shares = append(shares, s) + } + sort.Slice(shares, func(i, j int) bool { return shares[i].Identifier < shares[j].Identifier }) + + signature, err := engine.Aggregate(signingPackage, shares, groupPublicKey) + if err != nil { + if reportFrostSubprocessSkip("aggregate", err) { + return + } + fmt.Printf("%saggregate: %v\n", ddkgErrPrefix, err) + return + } + if len(signature) != 64 { + fmt.Printf("%sunexpected signature length %d\n", ddkgErrPrefix, len(signature)) + return + } + fmt.Printf("%s%s\n", ddkgSigPrefix, hex.EncodeToString(signature)) +} + +// ---- collector ---- + +type ddkgCollector struct { + mu sync.Mutex + self int + byPhase map[string]map[int][]byte + bump chan struct{} +} + +func newDdkgCollector(self int) *ddkgCollector { + return &ddkgCollector{self: self, byPhase: map[string]map[int][]byte{}, bump: make(chan struct{}, 1024)} +} + +func (c *ddkgCollector) put(phase string, sender int, payload []byte) { + c.mu.Lock() + m := c.byPhase[phase] + if m == nil { + m = map[int][]byte{} + c.byPhase[phase] = m + } + if _, ok := m[sender]; !ok { + m[sender] = payload + } + c.mu.Unlock() + select { + case c.bump <- struct{}{}: + default: + } +} + +func (c *ddkgCollector) collect(ctx context.Context, phase string, want []int, timeout time.Duration) (map[int][]byte, error) { + deadline := time.Now().Add(timeout) + for { + c.mu.Lock() + have := map[int][]byte{} + if m := c.byPhase[phase]; m != nil { + for _, s := range want { + if p, ok := m[s]; ok { + have[s] = p + } + } + } + c.mu.Unlock() + if len(have) == len(want) { + return have, nil + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("phase %s: got %d of %d before timeout", phase, len(have), len(want)) + } + select { + case <-c.bump: + case <-time.After(200 * time.Millisecond): + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +// ---- helpers ---- + +func ddkgSend(ctx context.Context, channel keepnet.BroadcastChannel, phase string, sender, recipient int, payload []byte) error { + return channel.Send(ctx, &ddkgMsg{Phase: phase, Sender: sender, Recipient: recipient, Payload: payload}) +} + +func mustJSON(v interface{}) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(fmt.Sprintf("ddkg marshal: %v", err)) + } + return b +} + +// ecdhKey derives a shared AES key from secp256k1 ECDH between my scalar and a peer's +// operator public key. Symmetric: ecdhKey(myD, peerPub) == ecdhKey(peerD, myPub). +func ecdhKey(myD []byte, peerPub *operator.PublicKey) []byte { + sx, _ := local_v1.DefaultCurve.ScalarMult(peerPub.X, peerPub.Y, myD) + sum := sha256.Sum256(sx.Bytes()) + return sum[:] +} + +func sealForPeer(myD []byte, peerPub *operator.PublicKey, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(ecdhKey(myD, peerPub)) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +func openFromPeer(myD []byte, peerPub *operator.PublicKey, sealed []byte) ([]byte, error) { + block, err := aes.NewCipher(ecdhKey(myD, peerPub)) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(sealed) < gcm.NonceSize() { + return nil, fmt.Errorf("sealed payload too short") + } + nonce, ct := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():] + return gcm.Open(nil, nonce, ct, nil) +} diff --git a/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go b/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go new file mode 100644 index 0000000000..557440b8bc --- /dev/null +++ b/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go @@ -0,0 +1,179 @@ +//go:build frost_native + +package signing + +import ( + "context" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// driveInteractiveRoastSigningIfEnabled drives ONE interactive ROAST signing +// attempt for the local node, using the attempt handle + context minted for +// THIS Execute call by attemptRoastRetryOrchestrationFromRequest. The handle is +// passed in, NEVER looked up by session id: a multi-seat operator runs one +// concurrent Execute per seat (signing.go launches a goroutine per signer) and +// every seat shares one SessionID (signingSessionID is member-independent), so +// the session-keyed handle registry would return another seat's overwritten +// handle (or none after that seat's cleanup) -- which could make two seats mark +// the same handle succeeded, or drop one seat to coarse while peers stay +// interactive. Threading the minted handle keeps each seat bound to its own. +// +// Retry-loop ownership (per the Phase 7.3 executor-wiring design consult): the +// existing tBTC signingRetryLoop owns retries -- it re-invokes the executor +// (and therefore this helper) once per attempt with a fresh attempt context. +// This helper drives exactly one attempt; it never loops. On a runner failure it +// stashes any coordinator-equivocation proofs the collector retained (consumed by +// the transition exchange's BroadcastForcedSnapshot) and returns the error so the +// outer loop advances and drives the transition. The deferred orchestration +// cleanup only clears the per-attempt handle binding. +// +// Return contract -- (signature, error): +// - (sig, nil) the interactive attempt completed; the executor returns sig +// and skips the coarse primitive. +// - (nil, nil) interactive signing is not enabled for this session (audit +// gate off, or no engine registered); the executor falls through to the +// coarse primitive. +// - (nil, err) the node had COMMITTED to interactive signing and a step +// failed. This HARD-FAILS rather than silently falling back to coarse: an +// honest node dropping to the legacy path while peers proceed interactively +// would fracture the signing group. The runner-failure case is included +// here -- the outer loop retries the next attempt. +// +// The two env gates (orchestration readiness + this audit gate) must be set +// consistently across the signing group; an inconsistent deployment splits the +// group exactly as an inconsistent KEEP_CORE_FROST_ROAST_RETRY_ENABLED would, +// and is an operator responsibility for this gated rollout. +func driveInteractiveRoastSigningIfEnabled( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, + handle roast.AttemptHandle, + attemptCtx attempt.AttemptContext, +) (*frost.Signature, error) { + if logger == nil { + logger = log.Logger("keep-frost-interactive-signing") + } + + // Front door 1: the audit gate. Off -> coarse path, no diagnostics noise. + if !InteractiveSigningOptInEnabled() { + return nil, nil + } + + // Front door 2: an engine provider must be registered. Absent is a + // deployment-in-progress state (e.g. production has not registered the cgo + // engine yet, or the audit has not cleared), NOT a runtime fault -> coarse. + engine := registeredInteractiveSigningEngine() + if engine == nil { + logger.Infof( + "interactive ROAST signing gated on but no engine registered "+ + "for session %q; using coarse path", + request.SessionID, + ) + return nil, nil + } + + // The coordinator deps are re-read from the registry (they were present when + // BeginOrchestrationForSession minted the handle above). A mid-session + // re-registration to a DIFFERENT coordinator would make NewActiveRoastAttempt's + // SelectedCoordinator(handle) return ErrUnknownAttempt and hard-fail below -- + // safe (no wrong signature), and only reachable by reconfiguring the + // coordinator mid-session. An absent registration falls back to coarse. + deps, ok := RegisteredRoastRetryCoordinatorForMember(request.MemberIndex) + if !ok || deps.Coordinator == nil { + return nil, nil + } + + // From here the node has COMMITTED to interactive signing: gate on, engine + // present, orchestration active. Every failure below HARD-FAILS. + dkgGroupPublicKey, err := ExtractDkgGroupPublicKeyFromMaterial(request.SignerMaterial) + if err != nil { + return nil, fmt.Errorf( + "interactive ROAST signing: extract dkg group public key: %w", err, + ) + } + + threshold, err := interactiveRoastSigningThreshold(request) + if err != nil { + return nil, fmt.Errorf("interactive ROAST signing: %w", err) + } + + // Bind the attempt (and therefore the interactive engine session) to the + // STABLE attemptCtx.SessionID, NOT request.SessionID: NewActiveRoastAttempt + // requires sessionID == ctx.SessionID, and ctx.SessionID is the RoastSessionID + // (the engine session is unified on the stable id - it separates attempts by + // the canonical attempt id). request.SessionID is the attempt-specific coarse + // id and would be rejected here. + active, err := NewActiveRoastAttempt( + deps.Coordinator, + handle, + attemptCtx, + attemptCtx.SessionID, + request.TaprootMerkleRoot, + dkgGroupPublicKey, + ) + if err != nil { + return nil, fmt.Errorf("interactive ROAST signing: bind attempt: %w", err) + } + + bus, err := NewBroadcastChannelRunnerBus( + ctx, logger, request.Channel, request.MembershipValidator, + ) + if err != nil { + return nil, fmt.Errorf("interactive ROAST signing: build transport bus: %w", err) + } + + collector := roast.NewRound2Collector(deps.Verifier) + + runner, err := newInteractiveSigningRunner( + active, + request.MemberIndex, + threshold, + engine, + collector, + deps.Coordinator, + deps.Signer, + bus, + ) + if err != nil { + return nil, fmt.Errorf("interactive ROAST signing: build runner: %w", err) + } + + signatureBytes, err := runner.Run(ctx) + if err != nil { + // The attempt was driven and failed. Before propagating, surface any + // coordinator-signed package proofs the collector retained -- it is NOT + // pruned on failure (roast_runner_frost_native.go), so the authoritative + // package (plus any body-different one the coordinator equivocated to this + // seat) is still held. Stashing them lets BroadcastForcedSnapshot carry them + // in this seat's snapshot; the bundle's aggregated proofs let NextAttempt + // instant-exclude an equivocating coordinator (RFC-21 Phase 7.3 PR2b-2 step + // 2b). An empty / unknown-attempt result stashes nothing. + attemptHash := attemptCtx.Hash() + if proofs, perr := collector.CoordinatorPackageProofs(attemptHash[:]); perr == nil { + stashPendingCoordinatorProofs( + attemptCtx.SessionID, request.MemberIndex, attemptHash, proofs, + ) + } + // RFC-21 Phase 7.3 share-blame (the third fault source): if the aggregate + // failed on share verification, classify the engine's candidate culprits + // against this seat's retained shares and stash the resulting f+1 reject + // accusations -- carried alongside the proofs in the same union + // pending-evidence entry, so one failed attempt can publish both. + stashInteractiveShareBlame(err, attemptCtx, request, collector, engine) + // Propagate so the outer signingRetryLoop advances and drives the transition + // exchange (OnAttemptFailed -> BroadcastForcedSnapshot). + return nil, fmt.Errorf("interactive ROAST signing attempt: %w", err) + } + + signature, err := decodeBuildTaggedTBTCSignerSignature(signatureBytes) + if err != nil { + return nil, fmt.Errorf("interactive ROAST signing: decode signature: %w", err) + } + + return signature, nil +} diff --git a/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go b/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go new file mode 100644 index 0000000000..3a64a414ef --- /dev/null +++ b/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go @@ -0,0 +1,354 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// noopBroadcastChannel is a net.BroadcastChannel that never delivers a message. +// The drive's happy-path test runs a 1-of-1 attempt, where the sole member +// records its own commitment and share and aggregates without awaiting any peer +// - so Send/Recv are exercised but no inbound delivery is needed. The channel's +// own behaviour (auth, demux, dedup, overflow) is covered by the bus net tests. +type noopBroadcastChannel struct{} + +func (noopBroadcastChannel) Name() string { return "interactive-drive-test" } +func (noopBroadcastChannel) Send(context.Context, net.TaggedMarshaler, ...net.RetransmissionStrategy) error { + return nil +} +func (noopBroadcastChannel) Recv(context.Context, func(net.Message)) {} +func (noopBroadcastChannel) SetUnmarshaler(func() net.TaggedUnmarshaler) {} +func (noopBroadcastChannel) SetFilter(net.BroadcastChannelFilter) error { return nil } + +func singleSeatValidator(t *testing.T) *group.MembershipValidator { + t.Helper() + sgn := local_v1.Connect(1, 1).Signing() + _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("generate operator key: %v", err) + } + addr := sgn.PublicKeyBytesToAddress(operator.MarshalUncompressed(publicKey)) + return group.NewMembershipValidator(&testutils.MockLogger{}, []chain.Address{addr}, sgn) +} + +// driveFixture is a consistent single-node (1-of-1) interactive signing setup: a +// registered coordinator, the attempt handle that coordinator minted, and a +// request whose persisted material's DKG public key matches the attempt-context +// seed. The handle + context are passed to the drive directly (as the executor +// entry does with the per-Execute handle), so no session-handle registry or +// readiness gate is involved. The fixture does NOT set the interactive audit +// gate or register the engine provider; each test opts into those so the +// front-door behaviour is isolated. +type driveFixture struct { + request *NativeExecutionFFISigningRequest + engine *fakeInteractiveSigningEngine + handle roast.AttemptHandle + attemptCtx attempt.AttemptContext +} + +func newDriveFixture(t *testing.T) driveFixture { + t.Helper() + + const ( + // attemptSessionID is the attempt-specific (coarse) id; roastSessionID is + // the STABLE one BuildAttemptContextFromRequest puts in ctx.SessionID. + // They DIFFER, mirroring production, so the drive must bind the attempt + // to ctx.SessionID (not request.SessionID, which NewActiveRoastAttempt + // would reject). + attemptSessionID = "interactive-attempt-session-1" + roastSessionID = "interactive-roast-session" + keyGroup = "interactive-key-group" + ) + dkgKey := []byte(keyGroup) + included := []group.MemberIndex{1} + + attemptCtx, err := attempt.NewAttemptContext( + roastSessionID, keyGroup, dkgKey, + [attempt.MessageDigestLength]byte{0x42}, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt context: %v", err) + } + + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + coord := roast.NewInMemoryCoordinatorWithSigning(1, signer, verifier) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: coord, + Signer: signer, + Verifier: verifier, + SelfMember: 1, + }) + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetInteractiveSigningEngineProviderForTest) + + // The handle is minted by the registered coordinator - exactly the handle + // the executor entry threads into the drive for this Execute. + handle, err := coord.BeginAttempt(attemptCtx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + + engine := newFakeInteractiveSigningEngine() + // A real engine derives the same coordinator the binding elected; the sole + // member (1) is the elected coordinator for a 1-of-1 attempt. + engine.coordinatorIdentifier = 1 + + request := &NativeExecutionFFISigningRequest{ + SessionID: attemptSessionID, + RoastSessionID: roastSessionID, + MemberIndex: 1, + Channel: noopBroadcastChannel{}, + MembershipValidator: singleSeatValidator(t), + SignerMaterial: persistedTBTCSignerMaterial(t, keyGroup, 1, 1), + } + + return driveFixture{request: request, engine: engine, handle: handle, attemptCtx: attemptCtx} +} + +func validBIP340Signature(t *testing.T) []byte { + t.Helper() + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatalf("private key: %v", err) + } + sig, err := schnorr.Sign(priv, make([]byte, 32)) + if err != nil { + t.Fatalf("schnorr sign: %v", err) + } + return sig.Serialize() +} + +func (f driveFixture) run(t *testing.T) (*frost.Signature, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return driveInteractiveRoastSigningIfEnabled( + ctx, &testutils.MockLogger{}, f.request, f.handle, f.attemptCtx, + ) +} + +func TestDriveInteractiveRoastSigning_HappyPath(t *testing.T) { + f := newDriveFixture(t) + want := validBIP340Signature(t) + f.engine.signature = want + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return f.engine }) + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + sig, err := f.run(t) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sig == nil { + t.Fatal("expected a signature on the interactive path") + } + serialized := sig.Serialize() + if string(serialized[:]) != string(want) { + t.Fatalf("signature mismatch:\n got %x\nwant %x", serialized[:], want) + } +} + +func TestDriveInteractiveRoastSigning_GateOffFallsBackToCoarse(t *testing.T) { + f := newDriveFixture(t) + // Everything is ready (handle minted, engine registered) EXCEPT the audit + // gate, proving the gate alone gates the interactive path. + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return f.engine }) + + sig, err := f.run(t) + if err != nil || sig != nil { + t.Fatalf("expected coarse fallback (nil,nil), got sig=%v err=%v", sig, err) + } +} + +func TestDriveInteractiveRoastSigning_NoEngineFallsBackToCoarse(t *testing.T) { + f := newDriveFixture(t) + // Gate on, handle minted, but no engine registered. + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + sig, err := f.run(t) + if err != nil || sig != nil { + t.Fatalf("expected coarse fallback (nil,nil), got sig=%v err=%v", sig, err) + } +} + +func TestDriveInteractiveRoastSigning_RunnerFailureHardFails(t *testing.T) { + f := newDriveFixture(t) + // The node has COMMITTED to interactive signing (gate on, engine present); + // a runner failure must propagate as an error so the outer tBTC + // signingRetryLoop advances, NOT silently drop to coarse. + f.engine.aggregateErr = fmt.Errorf("aggregate share verification failed") + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return f.engine }) + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + sig, err := f.run(t) + if err == nil { + t.Fatal("expected a hard-fail error on runner failure") + } + if sig != nil { + t.Fatalf("expected no signature on failure, got %v", sig) + } +} + +// TestDriveInteractiveRoastSigning_StashesCoordinatorProofsOnFailure covers the +// RFC-21 Phase 7.3 PR2b-2 step 2b extraction seam end-to-end through the real +// drive: when a committed interactive attempt fails AFTER the runner recorded the +// coordinator-signed package, the drive surfaces collector.CoordinatorPackageProofs +// and stashes them so BroadcastForcedSnapshot can carry them into the bundle (where +// NextAttempt's cross-observer tally adjudicates coordinator equivocation). A 1-of-1 +// attempt retains only its own authoritative package (no equivocation), so exactly +// one proof is stashed -- proving the seam fires through the real runner+collector, +// not a directly-seeded stash. +func TestDriveInteractiveRoastSigning_StashesCoordinatorProofsOnFailure(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + f := newDriveFixture(t) + // Fail at aggregation -- the runner records the coordinator's signing package + // (obtainSigningPackage -> RecordSigningPackage) BEFORE InteractiveAggregate, so + // the collector still holds it when the drive extracts on failure. + f.engine.aggregateErr = fmt.Errorf("aggregate share verification failed") + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return f.engine }) + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + if _, err := f.run(t); err == nil { + t.Fatal("expected a hard-fail error on runner failure") + } + + // The proofs are stashed under the attempt's (RoastSessionID==ctx.SessionID, + // member, attemptHash) -- the same key BroadcastForcedSnapshot reads. + evidence, proofs, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash()) + if !ok { + t.Fatal("a committed interactive failure must stash the retained coordinator package proof") + } + if len(proofs) != 1 { + t.Fatalf("a 1-of-1 attempt retains exactly its own authoritative package; got %d proofs", len(proofs)) + } + if len(evidence.Overflows)+len(evidence.Rejects)+len(evidence.Conflicts) != 0 { + t.Fatalf("interactive failure must stash proofs only, no coarse evidence; got %+v", evidence) + } +} + +// verifierCapableFakeEngine wraps the fake interactive engine with a configurable +// share re-verification verdict, so it ALSO satisfies Round2ShareVerifyingEngine +// (the plain fake does not). Used to drive the share-blame path (RFC-21 Phase 7.3). +type verifierCapableFakeEngine struct { + *fakeInteractiveSigningEngine + shareVerdict NativeShareVerificationVerdict + shareErr error +} + +func (e *verifierCapableFakeEngine) VerifySignatureShare( + _ string, _ []byte, _ []byte, _ uint16, _ *[32]byte, +) (NativeShareVerificationVerdict, error) { + return e.shareVerdict, e.shareErr +} + +// TestDriveInteractiveRoastSigning_SkipsShareBlameWithoutVerifierEngine asserts the +// share-blame path fails safe when the engine cannot re-verify shares: the plain +// fake engine does not implement Round2ShareVerifyingEngine, so the type-assert in +// the drive skips classification. The 2b coordinator-proof stash still fires (a +// 1-of-1 attempt retains its authoritative package), so the entry exists but +// carries NO reject evidence -- no false blame from a missing verifier. +func TestDriveInteractiveRoastSigning_SkipsShareBlameWithoutVerifierEngine(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + f := newDriveFixture(t) + f.engine.aggregateErr = &InteractiveAggregateShareVerificationError{CandidateCulprits: []uint16{1}} + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return f.engine }) + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + if _, err := f.run(t); err == nil { + t.Fatal("expected a hard-fail error on share-verification failure") + } + + evidence, proofs, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash()) + if !ok { + t.Fatal("the 2b proof stash should still produce an entry") + } + if len(evidence.Rejects) != 0 { + t.Fatalf("share-blame must be skipped without a verifier engine; got rejects %+v", evidence.Rejects) + } + if len(proofs) == 0 { + t.Fatal("the 2b authoritative package proof should still be stashed") + } +} + +// TestDriveInteractiveRoastSigning_StashesShareRejectBlameOnVerifiedInvalidShare is +// the share-blame happy path (RFC-21 Phase 7.3, the third fault source): an +// interactive aggregate that fails naming member 1 a candidate culprit, with an +// engine that re-verifies member 1's retained share INVALID, stashes an f+1 reject +// accusation against member 1 (alongside the 2b authoritative proof in the same +// union entry). +func TestDriveInteractiveRoastSigning_StashesShareRejectBlameOnVerifiedInvalidShare(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + f := newDriveFixture(t) + f.engine.aggregateErr = &InteractiveAggregateShareVerificationError{CandidateCulprits: []uint16{1}} + verifierEngine := &verifierCapableFakeEngine{ + fakeInteractiveSigningEngine: f.engine, + shareVerdict: NativeShareVerdictInvalid, + } + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return verifierEngine }) + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + if _, err := f.run(t); err == nil { + t.Fatal("expected a hard-fail error on share-verification failure") + } + + evidence, _, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash()) + if !ok { + t.Fatal("share-verification failure must stash reject evidence") + } + if len(evidence.Rejects[1]) == 0 { + t.Fatalf("member 1's engine-verified-invalid share must produce a reject accusation; got %+v", evidence.Rejects) + } +} + +// TestDriveInteractiveRoastSigning_SkipsShareBlameForMalformedCandidates guards the +// uint16->MemberIndex conversion: a zero or out-of-range (> uint8 max) candidate is +// dropped BEFORE classification, so a malformed engine candidate can never truncate +// into -- and falsely blame -- an honest seat. With every candidate dropped, no +// reject evidence is stashed (the 2b proof still is). +func TestDriveInteractiveRoastSigning_SkipsShareBlameForMalformedCandidates(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + f := newDriveFixture(t) + f.engine.aggregateErr = &InteractiveAggregateShareVerificationError{CandidateCulprits: []uint16{0, 300}} + verifierEngine := &verifierCapableFakeEngine{ + fakeInteractiveSigningEngine: f.engine, + shareVerdict: NativeShareVerdictInvalid, + } + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return verifierEngine }) + t.Setenv(InteractiveSigningOptInEnvVar, "true") + + if _, err := f.run(t); err == nil { + t.Fatal("expected a hard-fail error") + } + evidence, _, ok := takePendingEvidence(f.attemptCtx.SessionID, 1, f.attemptCtx.Hash()) + if !ok { + t.Fatal("the 2b proof stash should still produce an entry") + } + if len(evidence.Rejects) != 0 { + t.Fatalf("malformed candidates must produce no reject blame; got %+v", evidence.Rejects) + } +} diff --git a/pkg/frost/signing/roast_interactive_signing_frost_native.go b/pkg/frost/signing/roast_interactive_signing_frost_native.go new file mode 100644 index 0000000000..e5a916031b --- /dev/null +++ b/pkg/frost/signing/roast_interactive_signing_frost_native.go @@ -0,0 +1,92 @@ +//go:build frost_native + +package signing + +import ( + "fmt" + "sync" +) + +// interactiveSigningEngineProvider is the package-level injection seam for the +// interactiveSigningEngine the executor drives in the interactive ROAST path. +// It is a provider (factory) rather than a stored instance so each attempt gets +// a fresh engine handle and so the registration site need not import the +// concrete engine where the executor lives. +// +// Tests register a provider returning a programmable fake (under frost_native +// alone, no cgo). Production registers a provider returning the cgo-backed +// buildTaggedTBTCSignerEngine under frost_native && frost_tbtc_signer && cgo. +// Registration alone does NOT activate interactive signing: the executor still +// requires the InteractiveSigningOptInEnvVar audit gate (see +// roast_interactive_signing_gate.go), so the cgo engine stays dormant until the +// engine audit clears even on a build that has registered it. +var ( + interactiveSigningEngineProviderMu sync.RWMutex + interactiveSigningEngineProvider func() interactiveSigningEngine +) + +// RegisterInteractiveSigningEngineProvider installs the provider the executor +// uses to obtain an interactiveSigningEngine. A later registration fully +// replaces an earlier one. Passing nil clears the registration (the executor +// then falls back to the coarse path). +func RegisterInteractiveSigningEngineProvider(provider func() interactiveSigningEngine) { + interactiveSigningEngineProviderMu.Lock() + defer interactiveSigningEngineProviderMu.Unlock() + interactiveSigningEngineProvider = provider +} + +// registeredInteractiveSigningEngine returns a fresh engine from the registered +// provider, or nil when no provider is registered (or the provider itself +// returns nil). A nil result tells the executor to use the coarse path. +func registeredInteractiveSigningEngine() interactiveSigningEngine { + interactiveSigningEngineProviderMu.RLock() + provider := interactiveSigningEngineProvider + interactiveSigningEngineProviderMu.RUnlock() + if provider == nil { + return nil + } + return provider() +} + +// ResetInteractiveSigningEngineProviderForTest clears the registered provider. +// Tests defer it so a registration does not leak into other tests. +func ResetInteractiveSigningEngineProviderForTest() { + RegisterInteractiveSigningEngineProvider(nil) +} + +// interactiveRoastSigningThreshold resolves the FROST signing threshold for the +// interactive attempt from the persisted DKG key-group material. +// +// The threshold for a persisted FROST key group is fixed at DKG time +// (payload.DKGThreshold); it is NOT the per-attempt dishonest-threshold the +// coarse bootstrap path derives via DishonestThreshold+1. Driving the runner +// with DishonestThreshold+1 would sign under the wrong t-of-n for a real +// persisted group, so the interactive path reads DKGThreshold directly and +// hard-checks it against the participant set (mirroring the coarse persisted +// path's validation in buildTaggedTBTCSignerRunDKGInputsForPayload). +func interactiveRoastSigningThreshold(request *NativeExecutionFFISigningRequest) (uint16, error) { + if request == nil { + return 0, fmt.Errorf("request is nil") + } + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(request.SignerMaterial) + if err != nil { + return 0, fmt.Errorf("decode signer material payload: %w", err) + } + if payload.KeyGroupSource != NativeTBTCSignerKeyGroupSourceDKGPersisted { + return 0, fmt.Errorf( + "interactive signing requires a persisted DKG key group, got key-group source %q", + payload.KeyGroupSource, + ) + } + if payload.DKGThreshold == 0 { + return 0, fmt.Errorf("persisted DKG threshold is zero") + } + if int(payload.DKGThreshold) > len(payload.DKGParticipants) { + return 0, fmt.Errorf( + "persisted DKG threshold exceeds participant count: [%d] > [%d]", + payload.DKGThreshold, + len(payload.DKGParticipants), + ) + } + return payload.DKGThreshold, nil +} diff --git a/pkg/frost/signing/roast_interactive_signing_frost_native_test.go b/pkg/frost/signing/roast_interactive_signing_frost_native_test.go new file mode 100644 index 0000000000..62ca2edcd4 --- /dev/null +++ b/pkg/frost/signing/roast_interactive_signing_frost_native_test.go @@ -0,0 +1,126 @@ +//go:build frost_native + +package signing + +import ( + "encoding/json" + "fmt" + "testing" +) + +// persistedTBTCSignerMaterial builds a FrostTBTCSignerV1 signer material for a +// persisted DKG key group. The extracted DKG group public key is []byte(keyGroup) +// (see extractDkgGroupPublicKeyFromTBTCSignerV1), so callers that also build an +// attempt context must pass the same keyGroup bytes as the context's DKG key for +// the NewActiveRoastAttempt seed check to pass. Shared by the frost_native and +// frost_roast_retry interactive-signing tests. +func persistedTBTCSignerMaterial( + t *testing.T, + keyGroup string, + threshold uint16, + participants int, +) *NativeSignerMaterial { + t.Helper() + parts := make([]NativeTBTCSignerDKGParticipant, participants) + for i := range parts { + parts[i] = NativeTBTCSignerDKGParticipant{ + Identifier: uint16(i + 1), + PublicKeyHex: fmt.Sprintf("%064x", i+1), + } + } + raw, err := json.Marshal(NativeTBTCSignerMaterialPayload{ + KeyGroup: keyGroup, + KeyGroupSource: NativeTBTCSignerKeyGroupSourceDKGPersisted, + DKGThreshold: threshold, + DKGParticipants: parts, + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: raw, + } +} + +func TestRegisterInteractiveSigningEngineProvider(t *testing.T) { + // Establish a clean precondition rather than assume one: other tests in this + // package install an interactive provider as a global side effect (e.g. the + // default FFI-provider registration under the cgo build calls + // registerBuildTaggedNativeFROSTSigningEngine), so a bare "no provider yet" + // assertion is order dependent and fails under -shuffle / a focused -run. + // Resetting up front makes this test order-independent regardless of which + // tests ran before it. + ResetInteractiveSigningEngineProviderForTest() + defer ResetInteractiveSigningEngineProviderForTest() + + if got := registeredInteractiveSigningEngine(); got != nil { + t.Fatalf("expected nil engine after reset, got %T", got) + } + + want := newFakeInteractiveSigningEngine() + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return want }) + if got := registeredInteractiveSigningEngine(); got != want { + t.Fatalf("expected the registered engine, got %T", got) + } + + // A provider that returns nil yields a nil engine (coarse fallback). + RegisterInteractiveSigningEngineProvider(func() interactiveSigningEngine { return nil }) + if got := registeredInteractiveSigningEngine(); got != nil { + t.Fatalf("expected nil from a nil-returning provider, got %T", got) + } + + ResetInteractiveSigningEngineProviderForTest() + if got := registeredInteractiveSigningEngine(); got != nil { + t.Fatalf("expected nil engine after reset, got %T", got) + } +} + +func TestInteractiveRoastSigningThreshold(t *testing.T) { + // The persisted DKG threshold is returned verbatim - NOT derived from the + // per-attempt dishonest threshold. + material := persistedTBTCSignerMaterial(t, "key-group", 2, 3) + got, err := interactiveRoastSigningThreshold(&NativeExecutionFFISigningRequest{ + SignerMaterial: material, + // A dishonest threshold that, mis-used as DishonestThreshold+1, would + // yield 6 - the helper must ignore it and return the DKG threshold (2). + DishonestThreshold: 5, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 2 { + t.Fatalf("expected DKG threshold 2, got %d", got) + } +} + +func TestInteractiveRoastSigningThreshold_Rejects(t *testing.T) { + zeroThreshold := persistedTBTCSignerMaterial(t, "kg", 0, 3) + overParticipants := persistedTBTCSignerMaterial(t, "kg", 4, 3) + + bootstrap := persistedTBTCSignerMaterial(t, "kg", 2, 3) + var bootstrapPayload NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(bootstrap.Payload, &bootstrapPayload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + bootstrapPayload.KeyGroupSource = "bootstrap" + if raw, err := json.Marshal(bootstrapPayload); err != nil { + t.Fatalf("marshal: %v", err) + } else { + bootstrap.Payload = raw + } + + cases := map[string]*NativeExecutionFFISigningRequest{ + "nil request": nil, + "zero threshold": {SignerMaterial: zeroThreshold}, + "threshold > participants": {SignerMaterial: overParticipants}, + "non-persisted source": {SignerMaterial: bootstrap}, + } + for name, request := range cases { + t.Run(name, func(t *testing.T) { + if _, err := interactiveRoastSigningThreshold(request); err == nil { + t.Fatal("expected an error") + } + }) + } +} diff --git a/pkg/frost/signing/roast_interactive_signing_gate.go b/pkg/frost/signing/roast_interactive_signing_gate.go new file mode 100644 index 0000000000..c6bbf71ddf --- /dev/null +++ b/pkg/frost/signing/roast_interactive_signing_gate.go @@ -0,0 +1,61 @@ +package signing + +import ( + "os" + "strings" +) + +// InteractiveSigningOptInEnvVar is the operator audit gate for RFC-21 Phase 7.3 +// interactive ROAST signing. It is DISTINCT from the orchestration readiness +// gate (KEEP_CORE_FROST_ROAST_RETRY_ENABLED, see roast_retry_readiness.go): +// enabling ROAST retry orchestration must NOT, on its own, activate the +// interactive signing runner driving the real cgo engine. Interactive signing +// requires BOTH gates on plus a registered engine, so an operator who opts in +// to retry orchestration does not silently start exercising the (separately +// audited) interactive FROST path. +// +// This gate stays OFF by default and is intended to remain off in production +// until the frost-secp256k1-tr engine external audit clears. While off, the +// executor uses the coarse signing path; the interactive code still compiles +// and is exercised under test with a fake engine. +// +// The variable is read per call -- not cached -- so an operator can flip it +// during a debugging session without restarting the node, matching the +// readiness gate's convention. +const InteractiveSigningOptInEnvVar = "KEEP_CORE_FROST_INTERACTIVE_SIGNING_ENABLED" + +// InteractiveSigningOptInEnabled reports whether the interactive-signing audit +// gate is currently set to "true" (case-insensitive, whitespace-trimmed). +// Cheap to call. +func InteractiveSigningOptInEnabled() bool { + value := strings.TrimSpace(os.Getenv(InteractiveSigningOptInEnvVar)) + return strings.EqualFold(value, "true") +} + +// InteractiveSigningOnlyEnvVar is the no-coarse-fallback half of coarse-path +// retirement (RFC-21 Phase 7.3). When set to "true", the executor REFUSES to fall +// through to the coarse signing primitive: interactive signing is mandatory, and a +// session where it does not run fails CLOSED rather than silently signing via the +// retired coarse path. It is meant to be set ONLY together with the audit gate above +// (and a registered engine) - setting it on its own makes signing fail closed. +// +// It stays OFF by default and is intended to remain off in production until the +// frost-secp256k1-tr engine external audit clears and the tECDSA->FROST cutover is +// made: flipping it on IS that cutover for this node (the coarse path is no longer +// available as a fallback). Read per call, not cached, matching the audit gate. +// +// SCOPE: it presumes the node signs EXCLUSIVELY via the interactive tBTC-FROST path. +// The refusal is format-agnostic (it fails closed for every signer format the native +// executor handles, not only the tBTC-signer one); it closes BOTH the inner FFI coarse +// primitive and the outer legacy fallbacks (the latter via nativeExecutionFallbackAllowed); +// and in a build WITHOUT the interactive engine (no frost_native) it fails all native +// signing closed. Enable it only on a node running the frost_native interactive engine +// with the audit gate on. +const InteractiveSigningOnlyEnvVar = "KEEP_CORE_FROST_INTERACTIVE_SIGNING_ONLY" + +// InteractiveSigningOnlyEnabled reports whether interactive-only (no coarse +// fallback) mode is currently set to "true" (case-insensitive, whitespace-trimmed). +func InteractiveSigningOnlyEnabled() bool { + value := strings.TrimSpace(os.Getenv(InteractiveSigningOnlyEnvVar)) + return strings.EqualFold(value, "true") +} diff --git a/pkg/frost/signing/roast_interactive_signing_metrics.go b/pkg/frost/signing/roast_interactive_signing_metrics.go new file mode 100644 index 0000000000..30bba73837 --- /dev/null +++ b/pkg/frost/signing/roast_interactive_signing_metrics.go @@ -0,0 +1,93 @@ +package signing + +import ( + "sync/atomic" + + "github.com/keep-network/keep-core/pkg/clientinfo" +) + +// Interactive-signing observability counters (RFC-21 Phase 7.3, handoff §6.3). +// +// The interactive signing runner surfaces outcomes only via return values - it emits +// no logs or metrics of its own - so before the coordinated tECDSA->FROST flip there is +// no operator-visible signal for "is interactive signing working, and is anything +// failing closed?". These process-wide cumulative counters add that signal: +// +// - success/failure track whether the interactive path, once driven, produces +// signatures or fails on a committed attempt; +// - coarseFallbackRefused is the FLIP-SAFETY signal: it increments whenever the +// no-coarse-fallback mode (KEEP_CORE_FROST_INTERACTIVE_SIGNING_ONLY) terminally +// refuses a would-be coarse/legacy signing. During a staged flip a misconfigured +// node - interactive-only on but interactive signing not actually running - shows +// up here instead of silently failing every signing attempt. +// +// They are process-wide rather than per-session (operators want "how many today?"), +// follow the roast_retry_metrics.go pattern, are emitted in every build, and stay at +// zero until the gated interactive path actually runs - so they are inert by default. +var ( + interactiveSigningSuccessEvents atomic.Uint64 + interactiveSigningFailureEvents atomic.Uint64 + coarseFallbackRefusedEvents atomic.Uint64 +) + +// interactiveSigningMetricsApplication is the clientinfo application-label prefix; the +// registry concatenates it with each per-source name, so the final labels look like +// "frost_interactive_signing_success_total". +const interactiveSigningMetricsApplication = "frost_interactive_signing" + +const ( + interactiveSigningSuccessMetricName = "success_total" + interactiveSigningFailureMetricName = "failure_total" + interactiveSigningCoarseRefusedMetricName = "coarse_fallback_refused_total" +) + +// RegisterInteractiveSigningMetrics registers the cumulative interactive-signing +// counters with the supplied clientinfo registry. Operators call this from the node's +// startup sequence (alongside RegisterRoastRetryMetrics) so the counters appear in the +// Prometheus scrape. A nil registry is a no-op. +func RegisterInteractiveSigningMetrics(registry *clientinfo.Registry) { + if registry == nil { + return + } + registry.ObserveApplicationSource( + interactiveSigningMetricsApplication, + map[string]clientinfo.Source{ + interactiveSigningSuccessMetricName: func() float64 { + return float64(interactiveSigningSuccessEvents.Load()) + }, + interactiveSigningFailureMetricName: func() float64 { + return float64(interactiveSigningFailureEvents.Load()) + }, + interactiveSigningCoarseRefusedMetricName: func() float64 { + return float64(coarseFallbackRefusedEvents.Load()) + }, + }, + ) +} + +// recordInteractiveSigningSuccess marks one interactive signing attempt that produced a +// signature. +func recordInteractiveSigningSuccess() { + interactiveSigningSuccessEvents.Add(1) +} + +// recordInteractiveSigningFailure marks one committed interactive signing attempt that +// failed (a drive error - not the inactive fall-through, which produces no signature +// and no error). +func recordInteractiveSigningFailure() { + interactiveSigningFailureEvents.Add(1) +} + +// recordCoarseFallbackRefused marks one terminal no-coarse-fallback refusal +// (interactive-only mode declined to sign via the retired coarse/legacy path). +func recordCoarseFallbackRefused() { + coarseFallbackRefusedEvents.Add(1) +} + +// resetInteractiveSigningMetricsForTest clears the cumulative counters. Exposed only for +// the package's own tests; not a production helper. +func resetInteractiveSigningMetricsForTest() { + interactiveSigningSuccessEvents.Store(0) + interactiveSigningFailureEvents.Store(0) + coarseFallbackRefusedEvents.Store(0) +} diff --git a/pkg/frost/signing/roast_interactive_signing_metrics_test.go b/pkg/frost/signing/roast_interactive_signing_metrics_test.go new file mode 100644 index 0000000000..a2603f5c39 --- /dev/null +++ b/pkg/frost/signing/roast_interactive_signing_metrics_test.go @@ -0,0 +1,54 @@ +package signing + +import ( + "context" + "math/big" + "testing" + + "github.com/ipfs/go-log/v2" +) + +func TestInteractiveSigningMetrics_RecordFunctionsIncrement(t *testing.T) { + resetInteractiveSigningMetricsForTest() + t.Cleanup(resetInteractiveSigningMetricsForTest) + + recordInteractiveSigningSuccess() + recordInteractiveSigningSuccess() + recordInteractiveSigningFailure() + recordCoarseFallbackRefused() + recordCoarseFallbackRefused() + recordCoarseFallbackRefused() + + if got := interactiveSigningSuccessEvents.Load(); got != 2 { + t.Fatalf("success counter: got %d want 2", got) + } + if got := interactiveSigningFailureEvents.Load(); got != 1 { + t.Fatalf("failure counter: got %d want 1", got) + } + if got := coarseFallbackRefusedEvents.Load(); got != 3 { + t.Fatalf("coarse-refused counter: got %d want 3", got) + } +} + +func TestInteractiveSigningMetrics_CoarseRefusalAtLegacyBackendIncrements(t *testing.T) { + // End-to-end wiring: the legacy backend's interactive-only terminal refusal bumps + // the flip-safety counter (the signal that a node is configured interactive-only + // but is still being asked to sign coarsely). + resetInteractiveSigningMetricsForTest() + t.Cleanup(resetInteractiveSigningMetricsForTest) + t.Setenv(InteractiveSigningOnlyEnvVar, "true") + + _, err := newLegacyExecutionBackend().Execute( + context.Background(), log.Logger("test"), &Request{Message: big.NewInt(1)}, + ) + if err == nil { + t.Fatal("expected the interactive-only terminal refusal") + } + if got := coarseFallbackRefusedEvents.Load(); got != 1 { + t.Fatalf("coarse-refused counter after a legacy refusal: got %d want 1", got) + } +} + +func TestRegisterInteractiveSigningMetrics_NilRegistryIsNoOp(t *testing.T) { + RegisterInteractiveSigningMetrics(nil) // must not panic +} diff --git a/pkg/frost/signing/roast_observe_attempt_frost_native_roast_retry.go b/pkg/frost/signing/roast_observe_attempt_frost_native_roast_retry.go new file mode 100644 index 0000000000..3a98a83d6c --- /dev/null +++ b/pkg/frost/signing/roast_observe_attempt_frost_native_roast_retry.go @@ -0,0 +1,107 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// ObserveAttemptForTransition begins a LOCAL "observe" attempt for the given +// signing request against the registered ROAST-retry coordinator and stores the +// resulting handle/context/dkg-key binding keyed by (roastSessionID, member, +// attemptContextHash). +// +// RFC-21 Phase 7.3 PR2b-1b: every local seat -- including ones excluded from the +// attempt -- observes each attempt so it holds a coordinator-instance-local +// handle to later VerifyBundle the attempt's transition bundle and run +// NextAttempt for participant selection (an AttemptHandle is not portable across +// coordinator instances, so a receiver cannot use another node's handle). The +// observe binding is DISTINCT from the active signing path's drive handle: it is +// produced here for the transition machinery, not for the FROST signing rounds. +// +// It is a no-op (returns nil) under the deterministic static conditions every +// honest node observes identically -- no coordinator registered, material not +// extractable, or an attempt context that cannot be constructed -- so the caller +// proceeds without an observe binding. Only a BeginAttempt failure (genuine +// runtime fault) is returned, so the caller can log it; in PR2b-1b's +// observe-only wiring a missing binding surfaces later as a fail-closed +// selection, never a divergent one. +func ObserveAttemptForTransition( + request *Request, +) ([attempt.MessageDigestLength]byte, error) { + var zeroHash [attempt.MessageDigestLength]byte + if request == nil { + return zeroHash, fmt.Errorf("observe attempt: request is nil") + } + + // Respect the per-seat readiness + registration gate, exactly as the selector + // and BeginOrchestrationForSession do: when THIS seat has no registered + // coordinator (or readiness is opted out), observing is pointless (nothing + // consumes the binding) and must stay inert. A deterministic static condition + // every honest node sees identically per seat. RFC-21 Phase 7.3 PR2b-1.5: a + // multi-seat operator observes per local seat with that seat's coordinator. + if !RoastRetryActiveForMember(request.MemberIndex) { + return zeroHash, nil + } + + deps, ok := RegisteredRoastRetryCoordinatorForMember(request.MemberIndex) + if !ok || deps.Coordinator == nil { + // No coordinator registered for this seat -- static fallback. + return zeroHash, nil + } + + signerMaterial, err := request.NativeSignerMaterial() + if err != nil { + // Material not extractable (e.g. a UniFFI v1 deployment) -- deterministic + // static fallback. + return zeroHash, nil + } + + ffiRequest := &NativeExecutionFFISigningRequest{ + Message: request.Message, + SessionID: request.SessionID, + RoastSessionID: request.RoastSessionID, + MemberIndex: request.MemberIndex, + SignerMaterial: signerMaterial, + TaprootMerkleRoot: request.TaprootMerkleRoot, + Attempt: request.Attempt, + } + + attemptCtx, err := BuildAttemptContextFromRequest(ffiRequest) + if err != nil { + // Deterministic per-input construction failure -- static fallback (matches + // attemptRoastRetryOrchestrationFromRequest's handling). + return zeroHash, nil + } + + dkgGroupPublicKey, err := ExtractDkgGroupPublicKeyFromMaterial(signerMaterial) + if err != nil { + return zeroHash, nil + } + + // Begin a local attempt to mint a coordinator-instance-local handle bound to + // this attempt's context. BeginAttempt elects the coordinator deterministically + // from the included set, so every honest seat's observe binding agrees on the + // elected coordinator without exchanging anything. + handle, err := deps.Coordinator.BeginAttempt(attemptCtx) + if err != nil { + return zeroHash, fmt.Errorf("observe attempt: begin attempt: %w", err) + } + + // Key by the STABLE attemptCtx.SessionID (== RoastSessionID), matching the + // transition-record registry, plus the attempt context hash so the transition + // listener can resolve the binding from the hash an incoming bundle carries. + recordObservedAttempt( + attemptCtx.SessionID, + request.MemberIndex, + attemptCtx.Hash(), + observedAttemptBinding{ + handle: handle, + context: attemptCtx, + dkgGroupPublicKey: dkgGroupPublicKey, + }, + ) + return attemptCtx.Hash(), nil +} diff --git a/pkg/frost/signing/roast_observe_attempt_frost_native_roast_retry_test.go b/pkg/frost/signing/roast_observe_attempt_frost_native_roast_retry_test.go new file mode 100644 index 0000000000..e5fbe2de82 --- /dev/null +++ b/pkg/frost/signing/roast_observe_attempt_frost_native_roast_retry_test.go @@ -0,0 +1,101 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newObserveTestRequest() *Request { + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "tbtc-signer-observe-group", + }) + return &Request{ + Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}), + RoastSessionID: "observe-test-session", + MemberIndex: 2, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, + Attempt: &Attempt{ + Number: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 4, 5}, + ExcludedMembersIndexes: []group.MemberIndex{}, + }, + } +} + +func registerObserveTestCoordinator() { + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 2, + }) +} + +func TestObserveAttemptForTransition_StoresBinding(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetObservedAttemptRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetObservedAttemptRegistryForTest) + + registerObserveTestCoordinator() + + req := newObserveTestRequest() + if _, err := ObserveAttemptForTransition(req); err != nil { + t.Fatalf("observe must not error: %v", err) + } + if !ObservedAttemptStoredForTest(req.RoastSessionID, req.MemberIndex) { + t.Fatal("observe must store a binding for (roastSessionID, member)") + } +} + +func TestObserveAttemptForTransition_StaticFallback_NoCoordinator(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetObservedAttemptRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetObservedAttemptRegistryForTest) + + // No coordinator registered -> static fallback, no binding. + req := newObserveTestRequest() + if _, err := ObserveAttemptForTransition(req); err != nil { + t.Fatalf("static fallback must not error: %v", err) + } + if ObservedAttemptStoredForTest(req.RoastSessionID, req.MemberIndex) { + t.Fatal("no binding must be stored when no coordinator is registered") + } +} + +func TestObserveAttemptForTransition_StaticFallback_ReadinessOff(t *testing.T) { + // Env var empty -> opted out, even with a coordinator registered. + t.Setenv(RoastRetryReadinessOptInEnvVar, "") + ResetRoastRetryRegistrationForTest() + ResetObservedAttemptRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetObservedAttemptRegistryForTest) + + registerObserveTestCoordinator() + + req := newObserveTestRequest() + if _, err := ObserveAttemptForTransition(req); err != nil { + t.Fatalf("readiness-off fallback must not error: %v", err) + } + if ObservedAttemptStoredForTest(req.RoastSessionID, req.MemberIndex) { + t.Fatal("no binding must be stored when readiness opt-in is off") + } +} + +func TestObserveAttemptForTransition_NilRequest(t *testing.T) { + if _, err := ObserveAttemptForTransition(nil); err == nil { + t.Fatal("nil request must error") + } +} diff --git a/pkg/frost/signing/roast_observed_attempt_registry_frost_roast_retry.go b/pkg/frost/signing/roast_observed_attempt_registry_frost_roast_retry.go new file mode 100644 index 0000000000..46fe009d3c --- /dev/null +++ b/pkg/frost/signing/roast_observed_attempt_registry_frost_roast_retry.go @@ -0,0 +1,212 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ObservedAttemptRegistryTTL is how long an observe binding is retained before +// the background sweeper evicts it. Matches the session-handle TTL: an observe +// binding is useless once the session it tracks is archived. +const ObservedAttemptRegistryTTL = SessionHandleBindingTTL + +// observedAttemptKey scopes one local seat's observe binding to a specific +// attempt of a ROAST session. RFC-21 Phase 7.3 PR2b-1b has every local signer -- +// including ones excluded from the attempt -- BeginAttempt locally so it holds a +// coordinator-instance-local handle to verify a transition bundle and run +// NextAttempt. The attempt hash discriminates the per-attempt bindings of one +// (session, member); the transition listener looks a binding up by the hash the +// incoming bundle carries. +type observedAttemptKey struct { + sessionID string + member group.MemberIndex + attemptHash [attempt.MessageDigestLength]byte +} + +// observedAttemptBinding is the per-attempt observe state a receiver needs to +// verify the attempt's transition bundle and compute the next attempt: the local +// handle (its own coordinator instance produced it), the bound attempt context +// (NextAttempt reads it as the previous context), and the DKG group public key +// (NextAttempt derives the next seed from it). +type observedAttemptBinding struct { + handle roast.AttemptHandle + context attempt.AttemptContext + dkgGroupPublicKey []byte +} + +type observedAttemptEntry struct { + binding observedAttemptBinding + createdAt time.Time +} + +var ( + observedAttemptRegistryMu sync.RWMutex + observedAttemptRegistry = map[observedAttemptKey]observedAttemptEntry{} + // observedAttemptHistory records that (sessionID, member) observed an attempt + // hash, retained even after the per-attempt binding is consumed or cleared so + // the transition listener can tell a bundle for an attempt this seat NEVER + // observed (it skipped a window peers committed -> lost ROAST sync -> fail + // closed) apart from a duplicate bundle for one it already consumed (a benign + // retransmit). It shares the binding's lifetime: cleared at session end and + // swept by the same TTL. + observedAttemptHistory = map[observedAttemptKey]time.Time{} +) + +// recordObservedAttempt stores the observe binding for (sessionID, member, +// attemptHash) and marks the attempt as observed in the history. A later call for +// the same key overwrites the earlier binding (a re-observed attempt re-binds +// against the latest handle). +func recordObservedAttempt( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, + binding observedAttemptBinding, +) { + observedAttemptRegistryMu.Lock() + defer observedAttemptRegistryMu.Unlock() + now := time.Now() + key := observedAttemptKey{sessionID, member, attemptHash} + observedAttemptRegistry[key] = observedAttemptEntry{binding: binding, createdAt: now} + observedAttemptHistory[key] = now +} + +// observedAttempt returns the observe binding for (sessionID, member, +// attemptHash) plus a presence flag. +func observedAttempt( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, +) (observedAttemptBinding, bool) { + observedAttemptRegistryMu.RLock() + defer observedAttemptRegistryMu.RUnlock() + entry, ok := observedAttemptRegistry[observedAttemptKey{sessionID, member, attemptHash}] + if !ok { + return observedAttemptBinding{}, false + } + return entry.binding, true +} + +// attemptEverObserved reports whether (sessionID, member) observed the given +// attempt hash at any point this session -- including an attempt whose per-attempt +// binding has since been consumed or cleared. The transition listener uses it to +// tell a bundle for a NEVER-observed attempt (this seat skipped a window peers +// committed -> lost ROAST sync) apart from a duplicate bundle for an +// already-consumed attempt (a benign retransmit that must NOT trip lost sync). +func attemptEverObserved( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, +) bool { + observedAttemptRegistryMu.RLock() + defer observedAttemptRegistryMu.RUnlock() + _, ok := observedAttemptHistory[observedAttemptKey{sessionID, member, attemptHash}] + return ok +} + +// clearObservedAttempt removes the observe binding for (sessionID, member, +// attemptHash). Called once a verified transition record is stored for the +// attempt, on success, or at session end. +func clearObservedAttempt( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, +) { + observedAttemptRegistryMu.Lock() + defer observedAttemptRegistryMu.Unlock() + delete(observedAttemptRegistry, observedAttemptKey{sessionID, member, attemptHash}) +} + +// ClearObservedAttemptOnLocalSuccess clears the observe binding for an attempt +// this seat completed successfully (a valid signature aggregated on its drive +// handle). The observe handle otherwise stays in a collecting state, so an elected +// coordinator could still aggregate a failure bundle, and a peer's failure bundle +// would be stored as a transition record, for an attempt that actually SUCCEEDED. +// Clearing the binding -- the observed-history marker remains, so a later bundle +// for it is treated as a benign retransmit, not lost sync -- means no failure +// transition is synthesized or stored for the succeeded attempt, so a subsequent +// done-check failure fails closed (no fresh record) instead of consuming a +// dishonest failure transition (RFC-21 Phase 7.3 PR2b-1b B3). Exported so the +// pkg/tbtc transition controller can call it on local success. +func ClearObservedAttemptOnLocalSuccess( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, +) { + clearObservedAttempt(sessionID, member, attemptHash) +} + +// clearObservedAttemptsForSession removes every observe binding for +// (sessionID, member), regardless of attempt hash. The transition exchange calls +// it when the session ends (its listener context is done), so a signing whose +// attempts succeeded -- and therefore never produced a transition record to clear +// per-attempt via clearObservedAttempt -- does not leave its observe bindings +// behind. +func clearObservedAttemptsForSession(sessionID string, member group.MemberIndex) { + observedAttemptRegistryMu.Lock() + defer observedAttemptRegistryMu.Unlock() + for key := range observedAttemptRegistry { + if key.sessionID == sessionID && key.member == member { + delete(observedAttemptRegistry, key) + } + } + for key := range observedAttemptHistory { + if key.sessionID == sessionID && key.member == member { + delete(observedAttemptHistory, key) + } + } +} + +// ObservedAttemptStoredForTest reports whether any observe binding exists for +// (sessionID, member), regardless of attempt hash. Exported test seam so +// downstream-package tests can assert the controller stored a binding without +// reaching into the unexported registry. +func ObservedAttemptStoredForTest(sessionID string, member group.MemberIndex) bool { + observedAttemptRegistryMu.RLock() + defer observedAttemptRegistryMu.RUnlock() + for key := range observedAttemptRegistry { + if key.sessionID == sessionID && key.member == member { + return true + } + } + return false +} + +// ResetObservedAttemptRegistryForTest clears every observe binding. Test-only +// seam. +func ResetObservedAttemptRegistryForTest() { + observedAttemptRegistryMu.Lock() + defer observedAttemptRegistryMu.Unlock() + observedAttemptRegistry = map[observedAttemptKey]observedAttemptEntry{} + observedAttemptHistory = map[observedAttemptKey]time.Time{} +} + +// evictStaleObservedAttempts sweeps the registry and removes bindings older than +// maxAge. Exposed at the package level so tests can invoke it directly with +// small maxAge values and so the session-handle sweeper can share one background +// goroutine. +func evictStaleObservedAttempts(maxAge time.Duration) int { + cutoff := time.Now().Add(-maxAge) + observedAttemptRegistryMu.Lock() + defer observedAttemptRegistryMu.Unlock() + evicted := 0 + for key, entry := range observedAttemptRegistry { + if entry.createdAt.Before(cutoff) { + delete(observedAttemptRegistry, key) + evicted++ + } + } + // Sweep stale observed-history markers on the same cutoff so a long-abandoned + // session's lost-sync markers do not linger past their backstop TTL. + for key, observedAt := range observedAttemptHistory { + if observedAt.Before(cutoff) { + delete(observedAttemptHistory, key) + } + } + return evicted +} diff --git a/pkg/frost/signing/roast_observed_attempt_registry_frost_roast_retry_test.go b/pkg/frost/signing/roast_observed_attempt_registry_frost_roast_retry_test.go new file mode 100644 index 0000000000..bf704f34b8 --- /dev/null +++ b/pkg/frost/signing/roast_observed_attempt_registry_frost_roast_retry_test.go @@ -0,0 +1,101 @@ +//go:build frost_roast_retry + +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// TestClearObservedAttemptOnLocalSuccess asserts that clearing an observe binding +// on local success removes the active binding (so the observe handle no longer +// collects, and no failure transition can be synthesized for a succeeded attempt) +// while RETAINING the observed-history marker, so a later bundle for the attempt +// is a benign retransmit rather than lost sync (RFC-21 Phase 7.3 PR2b-1b B3). +func TestClearObservedAttemptOnLocalSuccess(t *testing.T) { + ResetObservedAttemptRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + + sessionID := "registry-success-session" + var member group.MemberIndex = 2 + var hash [attempt.MessageDigestLength]byte + hash[0] = 0x5a + + recordObservedAttempt(sessionID, member, hash, observedAttemptBinding{}) + + if _, ok := observedAttempt(sessionID, member, hash); !ok { + t.Fatal("binding must exist after observing") + } + if !attemptEverObserved(sessionID, member, hash) { + t.Fatal("history marker must exist after observing") + } + + ClearObservedAttemptOnLocalSuccess(sessionID, member, hash) + + if _, ok := observedAttempt(sessionID, member, hash); ok { + t.Fatal("binding must be cleared on local success") + } + if !attemptEverObserved(sessionID, member, hash) { + t.Fatal("history marker must survive a local-success clear (so a retransmit is not lost sync)") + } +} + +// TestAttemptEverObservedUnobserved asserts an attempt this seat never observed is +// reported as never-observed -- the signal the transition listener uses to trip +// lost sync. +func TestAttemptEverObservedUnobserved(t *testing.T) { + ResetObservedAttemptRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + + var hash [attempt.MessageDigestLength]byte + hash[0] = 0x77 + if attemptEverObserved("never-session", 1, hash) { + t.Fatal("an unobserved attempt must report not-ever-observed") + } +} + +// TestConsumeClearRetainsHistoryMarker asserts the per-attempt consume clear +// (clearObservedAttempt) drops the binding but keeps the observed-history marker, +// so a retransmit of a consumed bundle is distinguishable from a never-observed +// one. +func TestConsumeClearRetainsHistoryMarker(t *testing.T) { + ResetObservedAttemptRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + + sessionID := "registry-consume-session" + var member group.MemberIndex = 1 + var hash [attempt.MessageDigestLength]byte + hash[0] = 0x2b + recordObservedAttempt(sessionID, member, hash, observedAttemptBinding{}) + + clearObservedAttempt(sessionID, member, hash) + + if _, ok := observedAttempt(sessionID, member, hash); ok { + t.Fatal("binding must be cleared after consume") + } + if !attemptEverObserved(sessionID, member, hash) { + t.Fatal("history marker must survive a consume clear") + } +} + +// TestClearObservedAttemptsForSessionClearsHistory asserts session-end clearing +// drops the observed-history markers too, so a stale marker does not suppress a +// genuine lost-sync signal in a later session that reuses the same id. +func TestClearObservedAttemptsForSessionClearsHistory(t *testing.T) { + ResetObservedAttemptRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + + sessionID := "registry-session-end" + var member group.MemberIndex = 1 + var hash [attempt.MessageDigestLength]byte + hash[0] = 0x3c + recordObservedAttempt(sessionID, member, hash, observedAttemptBinding{}) + + clearObservedAttemptsForSession(sessionID, member) + + if attemptEverObserved(sessionID, member, hash) { + t.Fatal("session-end clear must drop the observed-history marker") + } +} diff --git a/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go b/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go new file mode 100644 index 0000000000..9c60a1558d --- /dev/null +++ b/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go @@ -0,0 +1,384 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/operator" +) + +// realCgoSessionSeq gives each invocation a unique session id so that in-process +// repeats (go test -count=N) over the shared, process-stable signer state path add +// a fresh DKG session instead of conflicting on a fixed one. +var realCgoSessionSeq atomic.Uint64 + +// This file is the REAL-cgo interactive signing test: a full FROST DKG that +// PERSISTS a key group, followed by one signer's interactive ROAST contribution +// driven through the engine's INTERACTIVE session API +// (DeriveInteractiveAttemptContext -> InteractiveSessionOpen -> InteractiveRound1) +// with real frost-secp256k1-tr cryptography. It complements: +// - TestBuildTaggedTBTCSignerInteractiveFROSTBridge_WithLinkedSigner, which +// proves the real crypto over the LOW-LEVEL Sign/Aggregate API by feeding +// KeyPackage bytes directly; and +// - the fake-engine runner suite (roast_runner_bus_net_e2e_frost_native_test.go), +// which proves the multi-node Go-side orchestration - a t-subset across nodes, +// observers, the real pkg/net transport - without crypto. +// This test covers the remaining gap: the interactive session API the runner uses, +// with the REAL engine and REAL crypto. +// +// Scope and why it is one member: the signer engine state is a PROCESS GLOBAL (a +// single Mutex) that holds ONE open interactive member per session - +// InteractiveSessionOpen is fingerprinted once per session and +// interactive_state_for_attempt_mut rejects any member_identifier other than the +// opener's. That is by design: each production node is its own process. The engine +// also requires threshold >= 2. So a SINGLE process can faithfully drive exactly +// one signer's real contribution (open + round 1), but NOT the full t-of-n +// finalize (NewSigningPackage over t commitments -> round 2 per member -> aggregate), +// which needs the other members' contributions from their own processes. A +// complete real-crypto signature therefore requires a multi-process harness; the +// multi-member orchestration itself is covered with the fake engine over the real +// transport in the runner net e2e. +// +// What this test does NOT prove: interactive round 2 or aggregate over the cgo +// session surface (those need >= 2 signers, i.e. multiple processes). It is a +// real-crypto round-1 / FFI-bridge / persisted-state integration test, not a full +// interactive end-to-end signature. +// +// The DKG -> interactive keyGroup glue is RunDKG: InteractiveSessionOpen resolves +// the signing key by a keyGroup IDENTIFIER (engine-internal persisted material), +// not KeyPackage bytes. RunDKG runs the full DKG and PERSISTS the result, keyed by +// the SESSION ID, under a returned keyGroup the interactive path resolves - the +// same flow production uses. Open then requires a completed DKG session of the +// same session_id, so RunDKG and the interactive flow share one session id. +// +// To run it, link the signer library so the frost_tbtc_* symbols resolve, e.g.: +// +// CGO_ENABLED=1 \ +// CGO_LDFLAGS="-L -lfrost_tbtc -Wl,-rpath," \ +// go test -tags "frost_native frost_tbtc_signer" \ +// -run TestRealCgoInteractiveSigning_MemberContribution ./pkg/frost/signing/ +// +// Every engine call is guarded by skipFrostUnavailable: when the lib is absent, or +// present but stale (an older dylib missing a newer symbol such as +// frost_tbtc_derive_interactive_attempt_context), the test SKIPS naming the +// operation, rather than failing - so it is inert in CI builds that do not link +// the Rust signer and runs only against a current lib. + +func TestRealCgoInteractiveSigning_MemberContribution(t *testing.T) { + setupRealCgoSignerState(t) + + engine := &buildTaggedTBTCSignerEngine{} + + // The engine requires threshold >= 2; the attempt's included set is the t-subset + // {1,2} over a 3-party DKG. This process drives the one local signer (member 1). + const threshold = 2 + // One session id for the whole flow: the engine keys the interactive session by + // the DKG session id, so RunDKG, derive, and open all use sessionID. It is unique + // per invocation so in-process repeats over the stable state path add a fresh + // session instead of conflicting on a fixed one. + sessionID := fmt.Sprintf("real-cgo-session-%d", realCgoSessionSeq.Add(1)) + const localMember = uint16(1) + participantIDs := []byte{1, 2, 3} + includedMembers := []byte{1, 2} + message := bytesOf(0x42, 32) + + // 1. Full DKG that persists a key group under sessionID, returning the keyGroup + // the signing path resolves. + keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold) + + // 2. Derive the attempt context for the t-subset, then drive the local signer's + // interactive contribution - the same calls the interactiveSigningRunner makes, + // here with real crypto against the DKG-persisted keyGroup. + derived, err := engine.DeriveInteractiveAttemptContext( + sessionID, + message, + keyGroup, + threshold, + 0, // 0-based attempt number; the bridge converts to the 1-based wire value + uint16sOf(includedMembers), + ) + skipFrostUnavailable(t, "derive interactive attempt context", err) + + open, err := engine.InteractiveSessionOpen( + sessionID, + localMember, + message, + keyGroup, + threshold, + nil, // key-path spend + derived.AttemptContext, + ) + skipFrostUnavailable(t, "interactive session open", err) + if open.AttemptID == "" { + t.Fatal("interactive session open returned an empty attempt id") + } + + // Round 1: the real engine generates this member's signing nonces and returns + // its public commitments. A non-empty commitment is the proof the real + // interactive crypto path (DKG-persisted key resolution -> commit) works. + commitmentData, err := engine.InteractiveRound1(sessionID, open.AttemptID, localMember) + skipFrostUnavailable(t, "interactive round 1", err) + if len(commitmentData) == 0 { + t.Fatal("interactive round 1 returned an empty commitment from the real engine") + } +} + +// TestRealCgoInteractiveSigning_MultiSeatAggregate drives TWO local seats through the +// FULL interactive signing flow in ONE process against the real cgo engine, producing a +// real 2-of-3 BIP-340 signature. This is the payoff of the multi-seat engine fix +// (member-keyed interactive_signing): before it, the second seat's InteractiveSessionOpen +// failed with SessionConflict, so a single process could only drive one seat's +// contribution (see _MemberContribution). Now both seats Open, Round1, and Round2 +// independently and their shares aggregate. Skip-guarded; runs only against a linked, +// CURRENT libfrost_tbtc that includes the multi-seat fix. +func TestRealCgoInteractiveSigning_MultiSeatAggregate(t *testing.T) { + setupRealCgoSignerState(t) + + engine := &buildTaggedTBTCSignerEngine{} + + const threshold = 2 + sessionID := fmt.Sprintf("real-cgo-multiseat-session-%d", realCgoSessionSeq.Add(1)) + participantIDs := []byte{1, 2, 3} + // Both seats are LOCAL members in this one process (the multi-seat case). + signingMembers := []byte{1, 2} + message := bytesOf(0x42, 32) + + keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold) + + derived, err := engine.DeriveInteractiveAttemptContext( + sessionID, + message, + keyGroup, + threshold, + 0, + uint16sOf(signingMembers), + ) + skipFrostUnavailable(t, "derive interactive attempt context", err) + frostIDByMember := map[byte]string{} + for _, id := range derived.FrostIdentifiers { + frostIDByMember[byte(id.ParticipantIdentifier)] = id.FrostIdentifier + } + + // Open + Round1 for BOTH seats in one process. The second Open succeeding (rather + // than SessionConflict) is exactly what the multi-seat engine fix enables. + attemptIDByMember := make(map[byte]string, len(signingMembers)) + commitments := make([]nativeFROSTCommitment, 0, len(signingMembers)) + for _, member := range signingMembers { + open, err := engine.InteractiveSessionOpen( + sessionID, + uint16(member), + message, + keyGroup, + threshold, + nil, // key-path spend + derived.AttemptContext, + ) + if isPreMultiSeatConflict(err) { + t.Skipf( + "linked libfrost_tbtc predates the multi-seat fix (member %d's Open conflicts "+ + "with a sibling's; rebuild it from a source with member-keyed "+ + "interactive_signing): %v", + member, err, + ) + } + skipFrostUnavailable(t, fmt.Sprintf("interactive session open (member %d)", member), err) + attemptIDByMember[member] = open.AttemptID + + commitmentData, err := engine.InteractiveRound1(sessionID, open.AttemptID, uint16(member)) + skipFrostUnavailable(t, fmt.Sprintf("interactive round 1 (member %d)", member), err) + commitments = append(commitments, nativeFROSTCommitment{ + Identifier: frostIDByMember[member], + Data: commitmentData, + }) + } + + // Both seats derive the SAME attempt id (the engine derives it member- + // independently); the aggregate below keys off one of them, so pin the invariant. + if a, b := attemptIDByMember[signingMembers[0]], attemptIDByMember[signingMembers[1]]; a != b { + t.Fatalf("local seats derived different attempt ids (%q vs %q)", a, b) + } + + signingPackage, err := engine.NewSigningPackage(message, commitments) + skipFrostUnavailable(t, "new signing package", err) + + // Round2 for BOTH seats: each releases its share independently; member 1's Round2 + // must not disturb member 2's live state (the per-member entry isolation). + shares := make([]nativeFROSTSignatureShare, 0, len(signingMembers)) + for _, member := range signingMembers { + shareData, err := engine.InteractiveRound2( + sessionID, + attemptIDByMember[member], + uint16(member), + signingPackage, + ) + skipFrostUnavailable(t, fmt.Sprintf("interactive round 2 (member %d)", member), err) + shares = append(shares, nativeFROSTSignatureShare{ + Identifier: frostIDByMember[member], + Data: shareData, + }) + } + + // Aggregate the two interactive shares into a real 2-of-3 BIP-340 signature - + // produced by two local seats in ONE process via the cgo bridge. The engine + // validates the shares + aggregate internally, so a non-error 64-byte result is a + // valid threshold signature (see _MemberContribution on the absent external + // keyGroup->pubkey accessor). + signature, err := engine.InteractiveAggregate( + sessionID, + attemptIDByMember[signingMembers[0]], + signingPackage, + shares, + nil, + ) + skipFrostUnavailable(t, "interactive aggregate", err) + if len(signature) != 64 { + t.Fatalf("unexpected multi-seat interactive signature length: %d", len(signature)) + } +} + +// setupRealCgoSignerState sets the linked-signer env the persisted-DKG interactive flow +// needs: the development profile, a fixed state encryption key, and a per-PROCESS state +// path - stable across -count=N (the signer binds its process-global state-file lock to +// the first path and refuses to switch) and unique across processes (so separate runs +// do not contend on one lock). Tests pair it with a unique session id per invocation so +// in-process repeats add a fresh DKG session rather than conflicting on a fixed one. +func setupRealCgoSignerState(t *testing.T) { + t.Helper() + t.Setenv("TBTC_SIGNER_PROFILE", "development") + t.Setenv("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false") + stateKey := make([]byte, 32) + for i := range stateKey { + stateKey[i] = byte(i + 1) + } + t.Setenv("TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX", hex.EncodeToString(stateKey)) + stateDir := filepath.Join( + os.TempDir(), + fmt.Sprintf("keep-frost-realcgo-state-%d", os.Getpid()), + ) + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatalf("create signer state dir: %v", err) + } + t.Setenv("TBTC_SIGNER_STATE_PATH", filepath.Join(stateDir, "signer-state")) +} + +// isPreMultiSeatConflict reports whether an InteractiveSessionOpen error is the +// SessionConflict a PRE-multi-seat-fix libfrost_tbtc returns when a second LOCAL seat +// opens a session a sibling already opened. The fix (member-keyed interactive_signing) +// makes that Open succeed; against an older but otherwise-linked lib the test skips (a +// stale-lib environment issue, like a missing symbol) rather than failing. Matched on +// the error text - this is test-only environment detection, not production control flow. +func isPreMultiSeatConflict(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "session conflict") +} + +// FrostRequireCgoEnvVar, when truthy, turns the "linked lib unavailable" SKIP into a +// FATAL. It is set by the frost-cgo-integration CI gate, which builds a current +// libfrost_tbtc and links it: there, a would-be skip means the lib is absent, stale, or +// failed to load - which must fail the job loudly rather than silently dropping the +// real-crypto coverage the gate exists to provide. Unset (local/normal CI), the tests +// skip as before so they stay inert where the lib is not linked. +const FrostRequireCgoEnvVar = "KEEP_CORE_FROST_REQUIRE_CGO" + +func frostCgoRequired() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(FrostRequireCgoEnvVar)), "true") +} + +const frostSubprocessSkipPrefixEnv = "KEEP_CORE_FROST_SUBPROCESS_SKIP_PREFIX" + +func frostUnavailableSkipMessage(op string, err error) string { + return fmt.Sprintf( + "linked tbtc-signer FFI symbol for %s unavailable (lib absent or stale; "+ + "rebuild libfrost_tbtc): %v", + op, err, + ) +} + +func reportFrostSubprocessSkip(op string, err error) bool { + if err == nil || !errors.Is(err, ErrNativeCryptographyUnavailable) || frostCgoRequired() { + return false + } + if prefix := os.Getenv(frostSubprocessSkipPrefixEnv); prefix != "" { + fmt.Printf("%s%s\n", prefix, frostUnavailableSkipMessage(op, err)) + } + return true +} + +// skipFrostUnavailable turns an engine-call error into the right outcome: a missing +// FFI symbol (lib absent, or present but stale and missing a newer symbol) SKIPS +// the test naming the operation, while any other error is a real failure. nil is a +// no-op. Centralizing this makes every step robust to an incomplete lib rather than +// only the first (RunDKG) call. Under the require-cgo gate the would-be skip is fatal. +func skipFrostUnavailable(t *testing.T, op string, err error) { + t.Helper() + if err == nil { + return + } + if errors.Is(err, ErrNativeCryptographyUnavailable) { + if frostCgoRequired() { + t.Fatalf( + "%s: tbtc-signer FFI symbol unavailable but %s is set (lib absent, stale, "+ + "or failed to load - the linked libfrost_tbtc must satisfy the bridge): %v", + op, FrostRequireCgoEnvVar, err, + ) + } + if prefix := os.Getenv(frostSubprocessSkipPrefixEnv); prefix != "" { + fmt.Printf("%s%s\n", prefix, frostUnavailableSkipMessage(op, err)) + } + t.Skip(frostUnavailableSkipMessage(op, err)) + } + t.Fatalf("%s: %v", op, err) +} + +// runRealCgoDKGKeyGroup runs a full real FROST DKG over the participants via +// RunDKG under sessionID, which persists the result (keyed by that session id) and +// returns the keyGroup the signing path resolves. It skips the test if the linked +// tbtc-signer FFI symbols are absent. Each participant carries a freshly generated +// operator public key (the DKG's per-participant identifying key), so the request +// is well-formed. +func runRealCgoDKGKeyGroup( + t *testing.T, + engine *buildTaggedTBTCSignerEngine, + sessionID string, + participantIDs []byte, + threshold uint16, +) string { + t.Helper() + + participants := make([]NativeTBTCSignerDKGParticipant, 0, len(participantIDs)) + for _, id := range participantIDs { + _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (participant %d): %v", id, err) + } + participants = append(participants, NativeTBTCSignerDKGParticipant{ + Identifier: uint16(id), + PublicKeyHex: hex.EncodeToString(operator.MarshalUncompressed(publicKey)), + }) + } + + result, err := engine.RunDKG(sessionID, participants, threshold) + skipFrostUnavailable(t, "run DKG", err) + if result.KeyGroup == "" { + t.Fatal("RunDKG returned an empty key group") + } + return result.KeyGroup +} + +// uint16sOf widens member ids to the uint16 participant list the engine's +// DeriveInteractiveAttemptContext expects. +func uint16sOf(members []byte) []uint16 { + out := make([]uint16, 0, len(members)) + for _, member := range members { + out = append(out, uint16(member)) + } + return out +} diff --git a/pkg/frost/signing/roast_retry_attempt_handle_default_build.go b/pkg/frost/signing/roast_retry_attempt_handle_default_build.go new file mode 100644 index 0000000000..bfc2290017 --- /dev/null +++ b/pkg/frost/signing/roast_retry_attempt_handle_default_build.go @@ -0,0 +1,54 @@ +//go:build !frost_roast_retry + +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// SetCurrentAttemptHandleForSession is a no-op in the default build: +// the receive loops will never find a handle for any (session, member), +// so the snapshot submission path is dormant. The build-tagged +// implementation does the real registration. +func SetCurrentAttemptHandleForSession( + _ string, + _ group.MemberIndex, + _ roast.AttemptHandle, + _ attempt.AttemptContext, +) { +} + +// ClearCurrentAttemptHandleForSession is a no-op in the default +// build. +func ClearCurrentAttemptHandleForSession(_ string, _ group.MemberIndex) {} + +// ResetSessionHandleRegistryForTest is a no-op in the default +// build. +func ResetSessionHandleRegistryForTest() {} + +// StartSessionHandleSweeper is a no-op in the default build: with +// no real registry there is nothing to sweep. +func StartSessionHandleSweeper() {} + +// currentAttemptHandleForCollect always returns ok=false in the +// default build, so submitSnapshotIfActive exits without attempting +// the RecordEvidence call. +func currentAttemptHandleForCollect( + _ string, + _ group.MemberIndex, +) (roast.AttemptHandle, attempt.AttemptContext, bool) { + return roast.AttemptHandle{}, attempt.AttemptContext{}, false +} + +// CurrentAttemptHandleForSession is the exported alias for +// callers outside the package (e.g. the ROAST-driven signing +// selector in pkg/tbtc). In the default build it is a no-op that +// always returns ok=false. +func CurrentAttemptHandleForSession( + sessionID string, + member group.MemberIndex, +) (roast.AttemptHandle, attempt.AttemptContext, bool) { + return currentAttemptHandleForCollect(sessionID, member) +} diff --git a/pkg/frost/signing/roast_retry_attempt_handle_frost_roast_retry.go b/pkg/frost/signing/roast_retry_attempt_handle_frost_roast_retry.go new file mode 100644 index 0000000000..3921ffe9d0 --- /dev/null +++ b/pkg/frost/signing/roast_retry_attempt_handle_frost_roast_retry.go @@ -0,0 +1,216 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// SessionHandleBindingTTL is the maximum age the eviction sweep +// tolerates for a sessionAttemptBinding before treating it as +// orphaned. The two-hour default is documented in RFC-21's +// Resolved decisions section: long enough that no real signing +// session reaches it, short enough that a leaked binding cannot +// accumulate across days of node uptime. +const SessionHandleBindingTTL = 2 * time.Hour + +// SessionHandleSweepInterval is how often the background sweeper +// goroutine wakes up to evict stale bindings. Coarse-grained on +// purpose: the sweep is a defence-in-depth backstop, not a tight +// liveness mechanism. 15 minutes balances responsiveness against +// goroutine churn. +const SessionHandleSweepInterval = 15 * time.Minute + +// sessionMemberKey identifies a binding by the signing session AND the +// local member seat it belongs to. RFC-21 Phase 7.3 PR2b-2 re-keyed the +// registry from sessionID alone to (sessionID, member): a multi-seat +// operator runs one receive loop per local seat, and each seat mints its +// own attempt handle from its own coordinator. Keying by sessionID alone +// let sibling seats collide -- the later Set overwrote the earlier seat's +// handle (mis-attributing its evidence) and one seat's cleanup deleted the +// shared binding out from under the survivor (silently disabling the +// survivor's inbound attempt-context-hash enforcement). The member +// component isolates each seat's binding so neither happens. +type sessionMemberKey struct { + sessionID string + member group.MemberIndex +} + +// sessionAttemptBinding records the current attempt's handle and +// context for a (session, member). The orchestration layer (Phase 5+) +// sets the binding via SetCurrentAttemptHandleForSession before driving +// the round-one / round-two / contribution receive loops; the +// receive loops read it at end-of-collect to know which attempt to +// submit their evidence snapshot against. +// +// createdAt is the wall-clock time at which the binding was last +// (re)set. The background sweeper evicts bindings older than +// SessionHandleBindingTTL. +type sessionAttemptBinding struct { + handle roast.AttemptHandle + context attempt.AttemptContext + createdAt time.Time +} + +var ( + sessionAttemptBindingMu sync.RWMutex + sessionAttemptBindings = map[sessionMemberKey]sessionAttemptBinding{} + + sweeperOnce sync.Once + sweeperStop chan struct{} +) + +// SetCurrentAttemptHandleForSession records the in-flight attempt +// handle and context for the named session and local member seat. +// Callers in the orchestration layer (Phase 5+) invoke this immediately +// after Coordinator.BeginAttempt so receive loops can correlate their +// captured evidence with the right attempt. +// +// The binding is keyed by (sessionID, member): a multi-seat operator's +// sibling seats each record under their own member so neither overwrites +// the other (RFC-21 Phase 7.3 PR2b-2). +// +// Later calls for the same (session, member) overwrite earlier ones (this +// is the documented behaviour: a session whose attempt has transitioned +// re-binds to the new attempt's handle). Begin/cleanup are scoped to a +// single Execute and the signingRetryLoop drives attempts strictly +// sequentially per session, so the overwrite never races a live earlier +// attempt's binding. +// +// The binding's createdAt is set to the current wall-clock time so +// the background sweeper can evict it if Clear is never called +// (panic before the deferred clear, etc.). +func SetCurrentAttemptHandleForSession( + sessionID string, + member group.MemberIndex, + handle roast.AttemptHandle, + ctx attempt.AttemptContext, +) { + sessionAttemptBindingMu.Lock() + defer sessionAttemptBindingMu.Unlock() + sessionAttemptBindings[sessionMemberKey{sessionID, member}] = sessionAttemptBinding{ + handle: handle, + context: ctx, + createdAt: time.Now(), + } +} + +// ClearCurrentAttemptHandleForSession removes any binding for the +// named session and member. Callers invoke this when a session +// terminates so the registry does not grow unbounded. Because the +// binding is member-keyed, a seat only clears its OWN binding -- a +// sibling seat's binding for the same session is untouched. +func ClearCurrentAttemptHandleForSession(sessionID string, member group.MemberIndex) { + sessionAttemptBindingMu.Lock() + defer sessionAttemptBindingMu.Unlock() + delete(sessionAttemptBindings, sessionMemberKey{sessionID, member}) +} + +// ResetSessionHandleRegistryForTest clears every binding and stops +// the background sweeper if one is running. Exposed only for +// tests; not for production code paths. +func ResetSessionHandleRegistryForTest() { + sessionAttemptBindingMu.Lock() + defer sessionAttemptBindingMu.Unlock() + sessionAttemptBindings = map[sessionMemberKey]sessionAttemptBinding{} + if sweeperStop != nil { + close(sweeperStop) + sweeperStop = nil + sweeperOnce = sync.Once{} + } +} + +// StartSessionHandleSweeper launches the background goroutine that +// evicts sessionAttemptBindings older than SessionHandleBindingTTL. +// Idempotent via sync.Once: the first caller starts the sweeper; +// subsequent calls are no-ops. The sweeper runs for the lifetime of +// the process (until ResetSessionHandleRegistryForTest stops it, +// which only tests do). +// +// Phase 5.2 starts the sweeper from RegisterRoastRetryCoordinator +// so the defence-in-depth backstop is active whenever orchestration +// could plausibly run. +func StartSessionHandleSweeper() { + sweeperOnce.Do(func() { + sessionAttemptBindingMu.Lock() + sweeperStop = make(chan struct{}) + stop := sweeperStop + sessionAttemptBindingMu.Unlock() + go sessionHandleSweepLoop(stop) + }) +} + +func sessionHandleSweepLoop(stop <-chan struct{}) { + ticker := time.NewTicker(SessionHandleSweepInterval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + evictStaleSessionHandleBindings(SessionHandleBindingTTL) + // Defense-in-depth backstop for the cross-attempt registries + // (RFC-21 Phase 7.3 PR2b-1b): observe bindings are normally cleared + // at session end and transition records are overwritten per attempt, + // but a session that ends abnormally could orphan either -- sweep + // anything past the TTL. + evictStaleObservedAttempts(ObservedAttemptRegistryTTL) + evictStaleRoastTransitions(RoastTransitionRegistryTTL) + // Stashed coarse-path evidence is normally consumed by + // BroadcastForcedSnapshot or cleared on success/session-end (RFC-21 + // Phase 7.3 PR2b-2 step 2); sweep any orphaned by an abnormal end. + evictStalePendingEvidence(PendingEvidenceRegistryTTL) + } + } +} + +// evictStaleSessionHandleBindings sweeps the binding map and +// removes entries older than maxAge. Exposed at the package level +// so tests can invoke it directly with small maxAge values without +// waiting for the sweeper ticker. +func evictStaleSessionHandleBindings(maxAge time.Duration) int { + cutoff := time.Now().Add(-maxAge) + sessionAttemptBindingMu.Lock() + defer sessionAttemptBindingMu.Unlock() + evicted := 0 + for key, binding := range sessionAttemptBindings { + if binding.createdAt.Before(cutoff) { + delete(sessionAttemptBindings, key) + evicted++ + } + } + return evicted +} + +// currentAttemptHandleForCollect reads the binding the orchestration +// layer set for this (session, member). Returns (zero, zero, false) when +// no binding exists -- the typical Phase-4 state, where no orchestration +// is wired yet. The submit helper takes ok=false as the signal to +// skip the RecordEvidence call. +func currentAttemptHandleForCollect( + sessionID string, + member group.MemberIndex, +) (roast.AttemptHandle, attempt.AttemptContext, bool) { + sessionAttemptBindingMu.RLock() + defer sessionAttemptBindingMu.RUnlock() + binding, ok := sessionAttemptBindings[sessionMemberKey{sessionID, member}] + if !ok { + return roast.AttemptHandle{}, attempt.AttemptContext{}, false + } + return binding.handle, binding.context, true +} + +// CurrentAttemptHandleForSession is the exported alias for callers +// outside the package (e.g. the ROAST-driven signing selector in +// pkg/tbtc). It is identical to currentAttemptHandleForCollect. +func CurrentAttemptHandleForSession( + sessionID string, + member group.MemberIndex, +) (roast.AttemptHandle, attempt.AttemptContext, bool) { + return currentAttemptHandleForCollect(sessionID, member) +} diff --git a/pkg/frost/signing/roast_retry_bundle_registry_default_build.go b/pkg/frost/signing/roast_retry_bundle_registry_default_build.go new file mode 100644 index 0000000000..6158e830c2 --- /dev/null +++ b/pkg/frost/signing/roast_retry_bundle_registry_default_build.go @@ -0,0 +1,24 @@ +//go:build !frost_roast_retry + +package signing + +import "github.com/keep-network/keep-core/pkg/protocol/group" + +// RecordRoastTransition is a no-op in the default build: the per-(session, +// member) transition-record registry is not active without the +// frost_roast_retry tag. The signing-loop ROAST selector (only compiled into +// the frost_roast_retry build) reads this registry; in the default build the +// legacy retry shuffle is always used. +func RecordRoastTransition(_ string, _ group.MemberIndex, _ RoastTransitionRecord) {} + +// RoastTransitionForSession returns (zero, false) in the default build, +// signalling to callers "no ROAST record; use the legacy retry shuffle". +func RoastTransitionForSession(_ string, _ group.MemberIndex) (RoastTransitionRecord, bool) { + return RoastTransitionRecord{}, false +} + +// ClearRoastTransitionForSession is a no-op in the default build. +func ClearRoastTransitionForSession(_ string, _ group.MemberIndex) {} + +// ResetRoastTransitionRegistryForTest is a no-op in the default build. +func ResetRoastTransitionRegistryForTest() {} diff --git a/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry.go b/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry.go new file mode 100644 index 0000000000..6d43bc9fde --- /dev/null +++ b/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry.go @@ -0,0 +1,104 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + "time" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// RoastTransitionRegistryTTL is how long a (session, member) transition +// record is retained before the background sweeper evicts it. Matches the +// session-handle TTL: a record's usefulness to retry-driven participant +// selection expires when the session it describes is itself archived. +const RoastTransitionRegistryTTL = SessionHandleBindingTTL + +// roastTransitionKey scopes a transition record to one local signer (member) +// within a ROAST session. A multi-seat operator runs one concurrent signer per +// seat sharing one roastSessionID; keying by session alone would collide (the +// #4081 multi-seat handle class). Each seat reads and writes its own record. +type roastTransitionKey struct { + sessionID string + member group.MemberIndex +} + +type roastTransitionEntry struct { + record RoastTransitionRecord + createdAt time.Time +} + +var ( + roastTransitionRegistryMu sync.RWMutex + roastTransitionRegistry = map[roastTransitionKey]roastTransitionEntry{} +) + +// RecordRoastTransition stores the most recent transition record produced for +// (sessionID, member). A later call for the same key overwrites the earlier +// record. A nil Bundle is ignored: a record without a bundle is useless to the +// selector (it has nothing to drive NextAttempt with). +func RecordRoastTransition( + sessionID string, + member group.MemberIndex, + record RoastTransitionRecord, +) { + if record.Bundle == nil { + return + } + roastTransitionRegistryMu.Lock() + defer roastTransitionRegistryMu.Unlock() + roastTransitionRegistry[roastTransitionKey{sessionID, member}] = roastTransitionEntry{ + record: record, + createdAt: time.Now(), + } +} + +// RoastTransitionForSession returns the most recent transition record for +// (sessionID, member), plus a presence flag. The ROAST selector treats +// (zero, false) as "no record; fall back to legacy". +func RoastTransitionForSession( + sessionID string, + member group.MemberIndex, +) (RoastTransitionRecord, bool) { + roastTransitionRegistryMu.RLock() + defer roastTransitionRegistryMu.RUnlock() + entry, ok := roastTransitionRegistry[roastTransitionKey{sessionID, member}] + if !ok { + return RoastTransitionRecord{}, false + } + return entry.record, true +} + +// ClearRoastTransitionForSession removes the record for (sessionID, member). +// Called when a session terminates. +func ClearRoastTransitionForSession(sessionID string, member group.MemberIndex) { + roastTransitionRegistryMu.Lock() + defer roastTransitionRegistryMu.Unlock() + delete(roastTransitionRegistry, roastTransitionKey{sessionID, member}) +} + +// ResetRoastTransitionRegistryForTest clears every record. Test-only seam. +func ResetRoastTransitionRegistryForTest() { + roastTransitionRegistryMu.Lock() + defer roastTransitionRegistryMu.Unlock() + roastTransitionRegistry = map[roastTransitionKey]roastTransitionEntry{} +} + +// evictStaleRoastTransitions sweeps the registry and removes entries older than +// maxAge. Exposed at the package level so tests can invoke it directly with +// small maxAge values and so the session-handle sweeper can share one +// background goroutine. +func evictStaleRoastTransitions(maxAge time.Duration) int { + cutoff := time.Now().Add(-maxAge) + roastTransitionRegistryMu.Lock() + defer roastTransitionRegistryMu.Unlock() + evicted := 0 + for key, entry := range roastTransitionRegistry { + if entry.createdAt.Before(cutoff) { + delete(roastTransitionRegistry, key) + evicted++ + } + } + return evicted +} diff --git a/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry_test.go new file mode 100644 index 0000000000..d3839b4c7d --- /dev/null +++ b/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry_test.go @@ -0,0 +1,132 @@ +//go:build frost_roast_retry + +package signing + +import ( + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" +) + +func testTransitionRecord(coordinator uint32) RoastTransitionRecord { + return RoastTransitionRecord{ + Bundle: &roast.TransitionMessage{CoordinatorIDValue: coordinator}, + DkgGroupPublicKey: []byte{0x01, 0x02}, + } +} + +func TestRoastTransitionRegistry_RoundTrip(t *testing.T) { + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetRoastTransitionRegistryForTest) + + RecordRoastTransition("session-A", 1, testTransitionRecord(7)) + + got, ok := RoastTransitionForSession("session-A", 1) + if !ok { + t.Fatal("expected record to be present after Record") + } + if got.Bundle.CoordinatorIDValue != 7 { + t.Fatalf("record round-trip mismatch: got coordinator %d, want 7", got.Bundle.CoordinatorIDValue) + } +} + +func TestRoastTransitionRegistry_MemberScoped(t *testing.T) { + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetRoastTransitionRegistryForTest) + + // Two seats of one operator share a session id; their records must NOT + // collide (the #4081 multi-seat handle class applied to transition state). + RecordRoastTransition("session", 1, testTransitionRecord(11)) + RecordRoastTransition("session", 2, testTransitionRecord(22)) + + got1, ok1 := RoastTransitionForSession("session", 1) + got2, ok2 := RoastTransitionForSession("session", 2) + if !ok1 || !ok2 { + t.Fatal("both members' records must be present") + } + if got1.Bundle.CoordinatorIDValue != 11 || got2.Bundle.CoordinatorIDValue != 22 { + t.Fatalf("member records collided: got %d and %d, want 11 and 22", + got1.Bundle.CoordinatorIDValue, got2.Bundle.CoordinatorIDValue) + } + // A member with no record reads absent (does not alias another seat's). + if _, ok := RoastTransitionForSession("session", 3); ok { + t.Fatal("member 3 has no record; lookup must be absent") + } +} + +func TestRoastTransitionRegistry_LaterRecordOverwrites(t *testing.T) { + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetRoastTransitionRegistryForTest) + + RecordRoastTransition("session-B", 1, testTransitionRecord(1)) + RecordRoastTransition("session-B", 1, testTransitionRecord(2)) + got, ok := RoastTransitionForSession("session-B", 1) + if !ok { + t.Fatal("expected record to be present") + } + if got.Bundle.CoordinatorIDValue != 2 { + t.Fatalf("later Record must overwrite earlier: got %d, want 2", got.Bundle.CoordinatorIDValue) + } +} + +func TestRoastTransitionRegistry_ClearRemovesRecord(t *testing.T) { + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetRoastTransitionRegistryForTest) + + RecordRoastTransition("session-clear", 1, testTransitionRecord(1)) + if _, ok := RoastTransitionForSession("session-clear", 1); !ok { + t.Fatal("setup: record must exist") + } + ClearRoastTransitionForSession("session-clear", 1) + if _, ok := RoastTransitionForSession("session-clear", 1); ok { + t.Fatal("record must be removed after Clear") + } +} + +func TestRoastTransitionRegistry_NilBundleIsIgnored(t *testing.T) { + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetRoastTransitionRegistryForTest) + + RecordRoastTransition("session-nil", 1, RoastTransitionRecord{Bundle: nil}) + if _, ok := RoastTransitionForSession("session-nil", 1); ok { + t.Fatal("a record with a nil bundle must be discarded") + } +} + +func TestEvictStaleRoastTransitions_RemovesOldEntries(t *testing.T) { + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetRoastTransitionRegistryForTest) + + RecordRoastTransition("session-old", 1, testTransitionRecord(1)) + // Backdate. + roastTransitionRegistryMu.Lock() + key := roastTransitionKey{"session-old", 1} + entry := roastTransitionRegistry[key] + entry.createdAt = time.Now().Add(-10 * time.Minute) + roastTransitionRegistry[key] = entry + roastTransitionRegistryMu.Unlock() + + RecordRoastTransition("session-new", 1, testTransitionRecord(2)) + + evicted := evictStaleRoastTransitions(5 * time.Minute) + if evicted != 1 { + t.Fatalf("expected 1 eviction, got %d", evicted) + } + if _, ok := RoastTransitionForSession("session-old", 1); ok { + t.Fatal("old record must be evicted") + } + if _, ok := RoastTransitionForSession("session-new", 1); !ok { + t.Fatal("new record must survive") + } +} + +func TestRoastTransitionRegistryTTL_MatchesSessionHandleTTL(t *testing.T) { + if RoastTransitionRegistryTTL != SessionHandleBindingTTL { + t.Fatalf( + "transition-record TTL %s != session-handle TTL %s; records must not outlive sessions", + RoastTransitionRegistryTTL, + SessionHandleBindingTTL, + ) + } +} diff --git a/pkg/frost/signing/roast_retry_bundle_registry_test.go b/pkg/frost/signing/roast_retry_bundle_registry_test.go new file mode 100644 index 0000000000..a2f0a1e1dc --- /dev/null +++ b/pkg/frost/signing/roast_retry_bundle_registry_test.go @@ -0,0 +1,32 @@ +//go:build !frost_roast_retry + +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" +) + +func TestRoastTransitionRegistry_DefaultBuildIsNoOp(t *testing.T) { + // In the default build the registry is a permanent stub: + // RecordRoastTransition discards; RoastTransitionForSession always returns + // (zero, false). The ROAST selector must therefore always fall back to + // legacy retry in the default build. + RecordRoastTransition( + "session-default-build-test", + 1, + RoastTransitionRecord{Bundle: &roast.TransitionMessage{}}, + ) + got, ok := RoastTransitionForSession("session-default-build-test", 1) + if ok { + t.Fatalf("default build registry must report not-present; got record %v", got) + } + if got.Bundle != nil { + t.Fatalf("default build must return a zero record; got bundle %v", got.Bundle) + } + + // Clear and reset must not panic. + ClearRoastTransitionForSession("session-default-build-test", 1) + ResetRoastTransitionRegistryForTest() +} diff --git a/pkg/frost/signing/roast_retry_evidence_stash_default_build.go b/pkg/frost/signing/roast_retry_evidence_stash_default_build.go new file mode 100644 index 0000000000..3105a7fe52 --- /dev/null +++ b/pkg/frost/signing/roast_retry_evidence_stash_default_build.go @@ -0,0 +1,32 @@ +//go:build !frost_roast_retry + +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// stashPendingEvidence is a no-op in the default build: with no ROAST-retry +// orchestration there is never a session-handle binding, so submitSnapshotIfActive +// returns before reaching this call. The build-tagged implementation does the real +// stashing the transition exchange's BroadcastForcedSnapshot consumes. +func stashPendingEvidence( + _ string, + _ group.MemberIndex, + _ [attempt.MessageDigestLength]byte, + _ attempt.Evidence, +) { +} + +// stashPendingCoordinatorProofs is a no-op in the default build, mirroring +// stashPendingEvidence: the interactive drive path (frost_native) calls it, but +// with no ROAST-retry orchestration there is no transition exchange to consume the +// proofs. The build-tagged implementation does the real stashing. +func stashPendingCoordinatorProofs( + _ string, + _ group.MemberIndex, + _ [attempt.MessageDigestLength]byte, + _ [][]byte, +) { +} diff --git a/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry.go b/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry.go new file mode 100644 index 0000000000..fb04ac5636 --- /dev/null +++ b/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry.go @@ -0,0 +1,256 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// PendingEvidenceRegistryTTL is how long a stashed evidence entry is retained +// before the background sweeper evicts it. Matches the observe-binding TTL: a +// stashed snapshot is useless once the session it tracks is archived. +const PendingEvidenceRegistryTTL = SessionHandleBindingTTL + +// pendingEvidenceKey scopes one local seat's captured coarse-path evidence to a +// specific attempt of a ROAST session. +// +// RFC-21 Phase 7.3 PR2b-2 step 2 (the blame bridge): the coarse receive loop +// captures real rejects/overflows/conflicts into an EvidenceRecorder and, at +// end-of-collect, stashes the raw snapshot here (submitSnapshotIfActive). The +// transition exchange's BroadcastForcedSnapshot then reads it so the seat's +// broadcast snapshot -- and therefore the elected coordinator's aggregated bundle +// -- carries real evidence instead of a forced-empty proof-of-attendance one, so +// NextAttempt's f+1 accuser tally can finally fire. +// +// The key is (RoastSessionID, member, attemptHash): the same coordinate space the +// observe binding uses (attemptHash == ctx.Hash() is namespace-independent, and +// the stashing ctx.SessionID is the STABLE RoastSessionID == the exchange's +// e.roastSessionID). The member component isolates a multi-seat operator's sibling +// seats: they share a RoastSessionID and attemptHash but each stashes -- and +// broadcasts -- its OWN evidence, so neither overwrites the other. +type pendingEvidenceKey struct { + sessionID string + member group.MemberIndex + attemptHash [attempt.MessageDigestLength]byte +} + +type pendingEvidenceEntry struct { + evidence attempt.Evidence + // coordinatorProofs holds the coordinator-signed signing-package proof + // envelope(s) the interactive path retained for the attempt (RFC-21 Phase 7.3 + // PR2b-2 step 2b). Empty for a coarse attempt. evidence and coordinatorProofs are + // independent: a coarse attempt carries only evidence, while ONE interactive + // failure can carry BOTH a coordinator proof (2b) and share-verification reject + // evidence (7.3 share-blame) -- NextAttempt reads the categories independently. + coordinatorProofs [][]byte + createdAt time.Time +} + +var ( + pendingEvidenceMu sync.Mutex + pendingEvidenceRegistry = map[pendingEvidenceKey]pendingEvidenceEntry{} +) + +// stashPendingEvidence stores a deep COPY of the captured evidence for +// (sessionID, member, attemptHash). A later call for the same key overwrites the +// earlier one (a re-driven attempt re-stashes). The deep copy means a later +// mutation of the caller's recorder snapshot cannot race the exchange's read. +func stashPendingEvidence( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, + evidence attempt.Evidence, +) { + key := pendingEvidenceKey{sessionID, member, attemptHash} + pendingEvidenceMu.Lock() + defer pendingEvidenceMu.Unlock() + // Upsert the evidence field, preserving any coordinator proofs already stashed + // for this attempt. An interactive failure legitimately stashes BOTH coordinator + // proofs (2b) and share-verification reject evidence (7.3 share-blame) for one + // attempt, so preserving the sibling field is load-bearing -- never an XOR + // assumption that would drop the other writer's data. + entry := pendingEvidenceRegistry[key] + entry.evidence = copyEvidence(evidence) + entry.createdAt = time.Now() + pendingEvidenceRegistry[key] = entry +} + +// stashPendingCoordinatorProofs stores deep COPIES of the coordinator-signed +// signing-package proof envelope(s) the interactive runner's Round2Collector +// retained for (sessionID, member, attemptHash). BroadcastForcedSnapshot carries +// them in this seat's snapshot so the bundle's aggregated proofs let NextAttempt +// instant-exclude an equivocating coordinator (RFC-21 Phase 7.3 PR2b-2 step 2b). +// A no-op when proofs is empty. Like stashPendingEvidence it upserts only its own +// field, preserving any coarse evidence already stashed for the attempt. +func stashPendingCoordinatorProofs( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, + proofs [][]byte, +) { + if len(proofs) == 0 { + return + } + key := pendingEvidenceKey{sessionID, member, attemptHash} + pendingEvidenceMu.Lock() + defer pendingEvidenceMu.Unlock() + entry := pendingEvidenceRegistry[key] + entry.coordinatorProofs = copyProofs(proofs) + entry.createdAt = time.Now() + pendingEvidenceRegistry[key] = entry +} + +// takePendingEvidence returns the stashed evidence AND coordinator proofs for +// (sessionID, member, attemptHash) plus a presence flag, REMOVING the entry +// (consume-on-read). The returned values are the sole references -- the entry is +// deleted from the registry -- so the caller owns them exclusively without a +// further copy. BroadcastForcedSnapshot calls it: a present entry means the coarse +// receive loop observed evidence (Evidence) and/or the interactive path retained +// coordinator-equivocation proofs ([][]byte) for the attempt; absent means neither +// was captured (the broadcast then carries an empty proof-of-attendance snapshot). +func takePendingEvidence( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, +) (attempt.Evidence, [][]byte, bool) { + key := pendingEvidenceKey{sessionID, member, attemptHash} + pendingEvidenceMu.Lock() + defer pendingEvidenceMu.Unlock() + entry, ok := pendingEvidenceRegistry[key] + if !ok { + return attempt.Evidence{}, nil, false + } + delete(pendingEvidenceRegistry, key) + return entry.evidence, entry.coordinatorProofs, true +} + +// clearPendingEvidence removes any stashed evidence for (sessionID, member, +// attemptHash). +func clearPendingEvidence( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, +) { + key := pendingEvidenceKey{sessionID, member, attemptHash} + pendingEvidenceMu.Lock() + defer pendingEvidenceMu.Unlock() + delete(pendingEvidenceRegistry, key) +} + +// ClearPendingEvidenceOnLocalSuccess removes the stashed evidence for an attempt +// this seat completed successfully. A succeeded attempt produces no transition +// bundle, so BroadcastForcedSnapshot never consumes the stash; clearing here +// prevents a leak until the TTL backstop. Exported so the pkg/tbtc transition +// controller can call it alongside ClearObservedAttemptOnLocalSuccess (RFC-21 +// Phase 7.3 PR2b-2). +func ClearPendingEvidenceOnLocalSuccess( + sessionID string, + member group.MemberIndex, + attemptHash [attempt.MessageDigestLength]byte, +) { + clearPendingEvidence(sessionID, member, attemptHash) +} + +// clearPendingEvidenceForSession removes every stashed evidence entry for +// (sessionID, member), regardless of attempt hash. The transition exchange calls +// it when the session ends (its listener context is done), mirroring +// clearObservedAttemptsForSession, so a signing whose attempts succeeded -- and +// therefore never consumed the stash via BroadcastForcedSnapshot -- does not leave +// entries behind. +func clearPendingEvidenceForSession(sessionID string, member group.MemberIndex) { + pendingEvidenceMu.Lock() + defer pendingEvidenceMu.Unlock() + for key := range pendingEvidenceRegistry { + if key.sessionID == sessionID && key.member == member { + delete(pendingEvidenceRegistry, key) + } + } +} + +// evictStalePendingEvidence sweeps the registry and removes entries older than +// maxAge. Exposed at the package level so tests can invoke it directly with small +// maxAge values and so the shared session-handle sweeper can fold it into one +// background goroutine: a session that ends abnormally must not orphan a stash +// entry past its backstop TTL. +func evictStalePendingEvidence(maxAge time.Duration) int { + cutoff := time.Now().Add(-maxAge) + pendingEvidenceMu.Lock() + defer pendingEvidenceMu.Unlock() + evicted := 0 + for key, entry := range pendingEvidenceRegistry { + if entry.createdAt.Before(cutoff) { + delete(pendingEvidenceRegistry, key) + evicted++ + } + } + return evicted +} + +// ResetPendingEvidenceRegistryForTest clears every stashed entry. Test-only seam; +// not for production code paths. +func ResetPendingEvidenceRegistryForTest() { + pendingEvidenceMu.Lock() + defer pendingEvidenceMu.Unlock() + pendingEvidenceRegistry = map[pendingEvidenceKey]pendingEvidenceEntry{} +} + +// PendingEvidenceStashedForTest reports whether any stash entry exists for +// (sessionID, member), regardless of attempt hash. Exported test seam so +// downstream-package tests can assert the stash without reaching into the +// unexported registry. +func PendingEvidenceStashedForTest(sessionID string, member group.MemberIndex) bool { + pendingEvidenceMu.Lock() + defer pendingEvidenceMu.Unlock() + for key := range pendingEvidenceRegistry { + if key.sessionID == sessionID && key.member == member { + return true + } + } + return false +} + +// copyEvidence returns a deep copy of an attempt.Evidence: the three maps are +// re-allocated and the per-sender RejectEntry slices are cloned. RejectEntry is a +// flat value type (Reason string, Count uint), so a slice copy is a full deep +// copy. nil maps stay nil (a nil category means "did not fire", which +// NewLocalEvidenceSnapshot and NextAttempt both treat as empty). +func copyEvidence(e attempt.Evidence) attempt.Evidence { + out := attempt.Evidence{} + if e.Overflows != nil { + out.Overflows = make(map[group.MemberIndex]uint, len(e.Overflows)) + for k, v := range e.Overflows { + out.Overflows[k] = v + } + } + if e.Rejects != nil { + out.Rejects = make(map[group.MemberIndex][]attempt.RejectEntry, len(e.Rejects)) + for k, v := range e.Rejects { + out.Rejects[k] = append([]attempt.RejectEntry(nil), v...) + } + } + if e.Conflicts != nil { + out.Conflicts = make(map[group.MemberIndex]uint, len(e.Conflicts)) + for k, v := range e.Conflicts { + out.Conflicts[k] = v + } + } + return out +} + +// copyProofs returns a deep copy of a slice of proof envelopes: the outer slice +// and each inner []byte are re-allocated, so a later mutation of the caller's +// slice cannot race the exchange's read. nil/empty stays nil. +func copyProofs(proofs [][]byte) [][]byte { + if len(proofs) == 0 { + return nil + } + out := make([][]byte, len(proofs)) + for i, p := range proofs { + out[i] = append([]byte(nil), p...) + } + return out +} diff --git a/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry_test.go new file mode 100644 index 0000000000..77f688e016 --- /dev/null +++ b/pkg/frost/signing/roast_retry_evidence_stash_frost_roast_retry_test.go @@ -0,0 +1,258 @@ +//go:build frost_roast_retry + +package signing + +import ( + "bytes" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func stashTestHash(b byte) [attempt.MessageDigestLength]byte { + var h [attempt.MessageDigestLength]byte + h[0] = b + return h +} + +func stashTestEvidence() attempt.Evidence { + return attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{2: 1}, + Rejects: map[group.MemberIndex][]attempt.RejectEntry{ + 3: {{Reason: "r", Count: 1}}, + }, + Conflicts: map[group.MemberIndex]uint{4: 1}, + } +} + +func TestPendingEvidenceStash_StoreTakeConsumes(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + hash := stashTestHash(0x11) + stashPendingEvidence("s", 1, hash, stashTestEvidence()) + + if !PendingEvidenceStashedForTest("s", 1) { + t.Fatal("entry must be present after store") + } + got, _, ok := takePendingEvidence("s", 1, hash) + if !ok { + t.Fatal("take must find the stored entry") + } + if got.Overflows[2] != 1 || got.Conflicts[4] != 1 || len(got.Rejects[3]) != 1 { + t.Fatalf("taken evidence does not match stored: %+v", got) + } + if _, _, ok := takePendingEvidence("s", 1, hash); ok { + t.Fatal("take must consume: a second take finds nothing") + } + if PendingEvidenceStashedForTest("s", 1) { + t.Fatal("entry must be gone after consume") + } +} + +// TestPendingEvidenceStash_DeepCopyIsolatesCallerMutation proves copyEvidence +// deep-copies on store: mutating the caller's Evidence (maps AND the per-sender +// reject slice) after stashing must not change what the exchange later reads. +func TestPendingEvidenceStash_DeepCopyIsolatesCallerMutation(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + hash := stashTestHash(0x22) + src := stashTestEvidence() + stashPendingEvidence("s", 1, hash, src) + + // Mutate every layer of the source after the store. + src.Overflows[2] = 99 + src.Overflows[5] = 7 // new key + src.Rejects[3][0].Count = 99 + src.Conflicts[4] = 99 + + got, _, ok := takePendingEvidence("s", 1, hash) + if !ok { + t.Fatal("take must find the stored entry") + } + if got.Overflows[2] != 1 { + t.Fatalf("overflow count must reflect store-time value 1; got %d", got.Overflows[2]) + } + if _, present := got.Overflows[5]; present { + t.Fatal("a key added to the source after store must not appear in the stash") + } + if got.Rejects[3][0].Count != 1 { + t.Fatalf("reject slice element must reflect store-time value 1; got %d", got.Rejects[3][0].Count) + } + if got.Conflicts[4] != 1 { + t.Fatalf("conflict count must reflect store-time value 1; got %d", got.Conflicts[4]) + } +} + +// TestPendingEvidenceStash_MemberKeyedIsolation asserts two seats sharing the same +// (sessionID, attemptHash) keep separate entries -- the member-keying that prevents +// a sibling seat from overwriting another's evidence. +func TestPendingEvidenceStash_MemberKeyedIsolation(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + hash := stashTestHash(0x33) + stashPendingEvidence("s", 1, hash, attempt.Evidence{Overflows: map[group.MemberIndex]uint{7: 1}}) + stashPendingEvidence("s", 2, hash, attempt.Evidence{Overflows: map[group.MemberIndex]uint{8: 1}}) + + got1, _, ok1 := takePendingEvidence("s", 1, hash) + got2, _, ok2 := takePendingEvidence("s", 2, hash) + if !ok1 || !ok2 { + t.Fatalf("both member entries must exist; ok1=%v ok2=%v", ok1, ok2) + } + if got1.Overflows[7] != 1 || got1.Overflows[8] != 0 { + t.Fatalf("seat 1 entry bled; got %+v", got1.Overflows) + } + if got2.Overflows[8] != 1 || got2.Overflows[7] != 0 { + t.Fatalf("seat 2 entry bled; got %+v", got2.Overflows) + } +} + +func TestPendingEvidenceStash_ClearForSession(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + stashPendingEvidence("s", 1, stashTestHash(0x01), stashTestEvidence()) + stashPendingEvidence("s", 1, stashTestHash(0x02), stashTestEvidence()) + stashPendingEvidence("other", 1, stashTestHash(0x03), stashTestEvidence()) + + clearPendingEvidenceForSession("s", 1) + + if PendingEvidenceStashedForTest("s", 1) { + t.Fatal("clearForSession must remove every attempt of (s,1)") + } + if !PendingEvidenceStashedForTest("other", 1) { + t.Fatal("clearForSession must not touch a different session") + } +} + +func TestClearPendingEvidenceOnLocalSuccess(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + hash := stashTestHash(0x44) + stashPendingEvidence("s", 1, hash, stashTestEvidence()) + ClearPendingEvidenceOnLocalSuccess("s", 1, hash) + if PendingEvidenceStashedForTest("s", 1) { + t.Fatal("success clear must remove the attempt's stash entry") + } +} + +// TestEvictStalePendingEvidence asserts the TTL backstop: a fresh entry survives a +// long TTL and is swept once the cutoff passes its creation time. A negative +// max-age sets the cutoff in the future, so the entry is deterministically stale +// without sleeping. +func TestEvictStalePendingEvidence(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + stashPendingEvidence("s", 1, stashTestHash(0x55), stashTestEvidence()) + + if n := evictStalePendingEvidence(time.Hour); n != 0 { + t.Fatalf("a fresh entry must survive a long TTL; evicted %d", n) + } + if !PendingEvidenceStashedForTest("s", 1) { + t.Fatal("entry must remain after a no-op sweep") + } + if n := evictStalePendingEvidence(-time.Hour); n != 1 { + t.Fatalf("a future cutoff must evict the entry; evicted %d", n) + } + if PendingEvidenceStashedForTest("s", 1) { + t.Fatal("entry must be gone after the sweep") + } +} + +// TestStashPendingCoordinatorProofs_StoreTakeConsumes is the 2b proof path: the +// interactive drive stashes coordinator-package proofs; takePendingEvidence returns +// them (with empty Evidence) and consumes the entry. +func TestStashPendingCoordinatorProofs_StoreTakeConsumes(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + hash := stashTestHash(0x61) + proofs := [][]byte{[]byte("auth-package"), []byte("conflicting-package")} + stashPendingCoordinatorProofs("s", 1, hash, proofs) + + if !PendingEvidenceStashedForTest("s", 1) { + t.Fatal("entry must be present after stashing proofs") + } + gotEv, gotProofs, ok := takePendingEvidence("s", 1, hash) + if !ok { + t.Fatal("take must find the stored proofs") + } + if len(gotEv.Overflows)+len(gotEv.Rejects)+len(gotEv.Conflicts) != 0 { + t.Fatalf("proof-only entry must carry empty evidence; got %+v", gotEv) + } + if len(gotProofs) != 2 || + !bytes.Equal(gotProofs[0], proofs[0]) || + !bytes.Equal(gotProofs[1], proofs[1]) { + t.Fatalf("taken proofs do not match stored: %q", gotProofs) + } + if _, _, ok := takePendingEvidence("s", 1, hash); ok { + t.Fatal("take must consume the proof entry") + } +} + +// TestStashPendingCoordinatorProofs_EmptyIsNoOp guards the empty guard: an attempt +// with no retained packages (CoordinatorPackageProofs returned nothing) stashes +// nothing. +func TestStashPendingCoordinatorProofs_EmptyIsNoOp(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + stashPendingCoordinatorProofs("s", 1, stashTestHash(0x62), nil) + stashPendingCoordinatorProofs("s", 1, stashTestHash(0x62), [][]byte{}) + if PendingEvidenceStashedForTest("s", 1) { + t.Fatal("empty proofs must not create a stash entry") + } +} + +// TestStashPendingCoordinatorProofs_DeepCopied proves copyProofs isolates the +// stash from later caller mutation of the proof bytes. +func TestStashPendingCoordinatorProofs_DeepCopied(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + hash := stashTestHash(0x63) + proof := []byte("package-bytes") + stashPendingCoordinatorProofs("s", 1, hash, [][]byte{proof}) + + proof[0] = 'X' // mutate the caller's slice after the store + + _, gotProofs, ok := takePendingEvidence("s", 1, hash) + if !ok || len(gotProofs) != 1 { + t.Fatalf("expected one stashed proof; ok=%v got=%q", ok, gotProofs) + } + if !bytes.Equal(gotProofs[0], []byte("package-bytes")) { + t.Fatalf("proof must reflect store-time bytes, not the mutation; got %q", gotProofs[0]) + } +} + +// TestPendingEvidenceStash_EvidenceAndProofsUnion asserts the union entry: stashing +// evidence and proofs under the SAME key (which the mutually-exclusive coarse and +// interactive paths normally never do) carries BOTH -- neither writer clobbers the +// other's field (Codex's "never an XOR assumption that drops data"). +func TestPendingEvidenceStash_EvidenceAndProofsUnion(t *testing.T) { + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + hash := stashTestHash(0x64) + stashPendingEvidence("s", 1, hash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{9: 1}, + }) + stashPendingCoordinatorProofs("s", 1, hash, [][]byte{[]byte("pkg")}) + + gotEv, gotProofs, ok := takePendingEvidence("s", 1, hash) + if !ok { + t.Fatal("union entry must exist") + } + if gotEv.Overflows[9] != 1 { + t.Fatalf("proof stash must not clobber the evidence field; got %+v", gotEv.Overflows) + } + if len(gotProofs) != 1 || !bytes.Equal(gotProofs[0], []byte("pkg")) { + t.Fatalf("evidence stash must not clobber the proofs field; got %q", gotProofs) + } +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_default_build.go b/pkg/frost/signing/roast_retry_executor_entry_default_build.go new file mode 100644 index 0000000000..eb7751639a --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_default_build.go @@ -0,0 +1,31 @@ +//go:build !frost_native + +package signing + +import ( + "context" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" +) + +// attemptRoastRetryOrchestrationFromRequest is the executor-adapter +// entry point for RFC-21 Phase-6 ROAST orchestration. In the +// default build (no frost_native tag) it is a permanent no-op +// stub: orchestration cannot run without the frost_native code +// path, so the executor adapter behaves exactly as in Phase 5. +// +// The function returns (signature, cleanup, error). In the +// frost_native build a non-nil signature means interactive signing +// produced it; cleanup is non-nil when orchestration started (the +// executor defers it); error is non-nil only for RUNTIME/committed +// failures the executor must propagate. +// +// The default-build stub returns (nil, nil, nil) unconditionally. +func attemptRoastRetryOrchestrationFromRequest( + _ context.Context, + _ *NativeExecutionFFISigningRequest, + _ log.StandardLogger, +) (*frost.Signature, func(), error) { + return nil, nil, nil +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_frost_native.go b/pkg/frost/signing/roast_retry_executor_entry_frost_native.go new file mode 100644 index 0000000000..aabeadbe4e --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_frost_native.go @@ -0,0 +1,144 @@ +//go:build frost_native + +package signing + +import ( + "context" + "errors" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" +) + +// attemptRoastRetryOrchestrationFromRequest is the executor-adapter +// entry point for RFC-21 Phase-6 ROAST orchestration. It: +// +// 1. Builds an attempt.AttemptContext from the FFI signing +// request (BuildAttemptContextFromRequest, gated frost_native). +// +// 2. If construction fails with ErrUnsupportedSignerMaterialFormat +// -- e.g. the deployment still uses FrostUniFFIV1 material -- +// the failure is a STATIC configuration condition: every +// honest signer with the same deployment material observes the +// same error deterministically. Log at INFO and return +// (nil, nil) so the executor proceeds without orchestration. +// +// 3. Any other AttemptContext construction error is a RUNTIME +// failure (nil fields, malformed material payload, etc.). Per +// the RFC-21 Phase-6 orchestration error taxonomy, runtime +// errors must HARD FAIL to prevent group fracture: node A +// falling back to legacy while node B proceeds with ROAST +// would split the participant set on NextAttempt. +// +// 4. Calls BeginOrchestrationForSession with the context. +// ErrRoastRetryReadinessOptOut and +// ErrNoRoastRetryCoordinatorRegistered are static-configuration +// errors -- log at INFO and return (nil, nil). Any other error +// is treated as RUNTIME and propagated unchanged. +// +// 5. With orchestration active, drives ONE gated interactive ROAST +// signing attempt (driveInteractiveRoastSigningIfEnabled) using +// the handle minted HERE for this Execute call -- never a +// session-keyed lookup, so concurrent multi-seat signers stay +// bound to their own attempt. Returns the signature when the +// interactive path handled signing; nil signature means the +// executor falls through to the coarse primitive. +// +// The function returns (signature, cleanup, error): +// - signature non-nil -> interactive signing produced it; executor returns it. +// - signature nil + cleanup non-nil + error nil -> orchestration active but +// interactive not enabled; defer cleanup, fall through to the coarse path. +// - signature nil + cleanup nil + error nil -> static fallback; coarse path. +// - error non-nil -> runtime/committed failure; propagate. cleanup may be +// non-nil (interactive runner failure) so the caller defers it to stash the +// failed attempt's transition bundle before returning the error. +func attemptRoastRetryOrchestrationFromRequest( + execCtx context.Context, + request *NativeExecutionFFISigningRequest, + logger log.StandardLogger, +) (*frost.Signature, func(), error) { + if logger == nil { + // Defensive: existing executor-adapter tests pass nil here. + // The helper logs static-fallback diagnostics, so a nil + // logger must not panic the executor. + logger = log.Logger("keep-frost-roast-orchestration") + } + attemptCtx, err := BuildAttemptContextFromRequest(request) + if err != nil { + // All BuildAttemptContextFromRequest errors are treated as + // STATIC fallbacks because they are deterministic per-input: + // the same NativeExecutionFFISigningRequest produces the + // same construction outcome on every honest node, so + // every node would make the same fall-back decision. The + // RFC-21 Phase-6 hard-fail discipline applies only to + // non-deterministic RUNTIME errors that originate inside + // the Coordinator state machine (next branch). + logger.Infof( + "ROAST orchestration unavailable for session %q: %v", + request.SessionID, + err, + ) + return nil, nil, nil + } + logger.Infof( + "ROAST signer-material telemetry: session=%q key_group_id=%q signer_material_format=%q", + request.SessionID, + attemptCtx.KeyGroupID, + request.SignerMaterial.Format, + ) + + // The handle registry is keyed by (request.SessionID, request.MemberIndex): + // the coarse receive-loop binding validation + snapshot submission look the + // handle up by that pair (RFC-21 Phase 7.3 PR2b-2), so a multi-seat operator's + // sibling seats stay isolated. The cross-attempt transition record is produced + // + keyed (by the stable RoastSessionID) entirely in the transition exchange + // now, not here. + handle, cleanup, err := BeginOrchestrationForSession( + request.SessionID, request.MemberIndex, attemptCtx, + ) + if err != nil { + switch { + case errors.Is(err, ErrRoastRetryReadinessOptOut), + errors.Is(err, ErrNoRoastRetryCoordinatorRegistered): + // Static-configuration errors -> safe to fall back. + logger.Infof( + "ROAST retry disabled for session %q: %v", + request.SessionID, + err, + ) + return nil, nil, nil + default: + // Runtime failure: HARD FAIL. + return nil, nil, fmt.Errorf( + "ROAST orchestration: begin session %q: %w", + request.SessionID, + err, + ) + } + } + + // Orchestration is active. Drive ONE gated interactive attempt with the + // handle minted HERE for this Execute (never a session-keyed lookup, so + // concurrent multi-seat signers do not collide). A nil signature with nil + // error means interactive signing is not enabled -> the executor falls + // through to the coarse primitive. A drive error is a committed-path + // failure: return it with the cleanup so the caller defers cleanup (stashing + // the failed attempt's transition bundle) before propagating. + signature, err := driveInteractiveRoastSigningIfEnabled( + execCtx, logger, request, handle, attemptCtx, + ) + // Observability (handoff §6.3): a committed interactive failure (err != nil) vs a + // produced signature (signature != nil) vs the inactive fall-through (both nil, + // interactive not enabled - counted as neither). This is the single chokepoint + // where the drive's outcome is interpreted, so instrument here rather than at the + // drive's many return points. + if err != nil { + recordInteractiveSigningFailure() + return nil, cleanup, err + } + if signature != nil { + recordInteractiveSigningSuccess() + } + return signature, cleanup, nil +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go b/pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go new file mode 100644 index 0000000000..4fca83f0aa --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go @@ -0,0 +1,147 @@ +//go:build frost_native + +package signing + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newEntryTestRequest(t *testing.T) *NativeExecutionFFISigningRequest { + t.Helper() + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "tbtc-signer-entry-group", + }) + return &NativeExecutionFFISigningRequest{ + Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}), + SessionID: "executor-entry-test", + MemberIndex: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 4, 5}, + }, + } +} + +func TestEntry_StaticFallback_NoCoordinatorRegistered_TaggedBuild(t *testing.T) { + // Without the frost_roast_retry build tag this is exercised by + // the default-build test (which always falls through). Under the + // frost_native build alone, the helper still treats the absence + // of a registered coordinator as a static fallback because + // BeginOrchestrationForSession returns + // ErrNoRoastRetryCoordinatorRegistered (in the default build it + // is the stub no-op-return-true). + // + // The helper must return (nil, nil) regardless: the executor + // adapter proceeds without orchestration, matching Phase 5 + // receive semantics. + logger := log.Logger("entry-static-test") + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), newEntryTestRequest(t), logger, + ) + if err != nil { + t.Fatalf("static fallback must not surface an error: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_LogsSignerMaterialFormatTelemetry(t *testing.T) { + logger := &captureInfoLogger{} + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), newEntryTestRequest(t), logger, + ) + if err != nil { + t.Fatalf("static fallback must not surface an error: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } + + joined := strings.Join(logger.infoMessages, "\n") + if !strings.Contains(joined, "signer_material_format") || + !strings.Contains(joined, NativeSignerMaterialFormatFrostTBTCSignerV1) || + !strings.Contains(joined, "key_group_id") { + t.Fatalf("missing signer-material telemetry in logs: [%s]", joined) + } +} + +func TestEntry_StaticFallback_UnsupportedSignerFormat(t *testing.T) { + // FrostUniFFIV1 material -> ExtractDkgGroupPublicKeyFromMaterial + // returns ErrUnsupportedSignerMaterialFormat. The helper must + // treat this as STATIC (deterministic across deployments) and + // fall back without surfacing an error. + req := newEntryTestRequest(t) + req.SignerMaterial = &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte("{}"), + } + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), req, log.Logger("entry-v1-test"), + ) + if err != nil { + t.Fatalf("V1 material must be a static fallback: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_StaticFallback_OnNilSignerMaterial(t *testing.T) { + // Nil signer material is a deterministic, per-input + // construction-precondition failure: every honest node with + // the same request would observe it identically. Treated as a + // STATIC fallback so the executor adapter proceeds without + // orchestration. The HARD-FAIL discipline is reserved for + // non-deterministic Coordinator state-machine errors. + req := newEntryTestRequest(t) + req.SignerMaterial = nil + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), req, log.Logger("entry-nil-mat-test"), + ) + if err != nil { + t.Fatalf("nil signer material must be a STATIC fallback; got %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return cleanup") + } +} + +type captureInfoLogger struct { + testutils.MockLogger + infoMessages []string +} + +func (cil *captureInfoLogger) Infof(format string, args ...interface{}) { + cil.infoMessages = append(cil.infoMessages, fmt.Sprintf(format, args...)) +} + +func TestEntry_StaticFallback_OnZeroAttemptNumber(t *testing.T) { + // Zero attempt number is also a deterministic precondition + // failure; treated as STATIC fallback. + req := newEntryTestRequest(t) + req.Attempt.Number = 0 + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), req, log.Logger("entry-zero-attempt-test"), + ) + if err != nil { + t.Fatalf("zero attempt number must be a STATIC fallback; got %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return cleanup") + } +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_executor_entry_frost_roast_retry_test.go new file mode 100644 index 0000000000..29c061ba82 --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_frost_roast_retry_test.go @@ -0,0 +1,194 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newEntryRetryTestRequest(t *testing.T) *NativeExecutionFFISigningRequest { + t.Helper() + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "tbtc-signer-entry-retry-group", + }) + return &NativeExecutionFFISigningRequest{ + Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}), + SessionID: "executor-entry-retry-test", + MemberIndex: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 4, 5}, + }, + } +} + +func TestEntry_StaticFallback_ReadinessOptInUnset(t *testing.T) { + // Explicitly unset the env var. + t.Setenv(RoastRetryReadinessOptInEnvVar, "") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Register a coordinator -- the env var alone keeps us in + // fallback. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), newEntryRetryTestRequest(t), log.Logger("entry-no-optin"), + ) + if err != nil { + t.Fatalf("static fallback (env var unset) must not surface an error: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_StaticFallback_RegistryEmpty(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Registry is empty (no Register call). + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), newEntryRetryTestRequest(t), log.Logger("entry-no-registry"), + ) + if err != nil { + t.Fatalf("static fallback (registry empty) must not surface an error: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_HappyPath_ActivatesOrchestration(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + req := newEntryRetryTestRequest(t) + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), req, log.Logger("entry-happy"), + ) + if err != nil { + t.Fatalf("happy path must not error: %v", err) + } + if cleanup == nil { + t.Fatal("happy path must return a cleanup function") + } + + // Binding must exist for the session under this seat's member. + if _, _, ok := currentAttemptHandleForCollect(req.SessionID, req.MemberIndex); !ok { + t.Fatal("binding must exist after orchestration entry") + } + cleanup() + if _, _, ok := currentAttemptHandleForCollect(req.SessionID, req.MemberIndex); ok { + t.Fatal("binding must be cleared after cleanup") + } +} + +func TestEntry_HardFail_RuntimeBeginAttemptFailure(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Register an erroring coordinator -- BeginAttempt fails for + // runtime reasons. Per the RFC-21 taxonomy, this must HARD FAIL. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: &erroringEntryCoordinator{ + err: errors.New("synthetic begin-attempt runtime failure"), + }, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), newEntryRetryTestRequest(t), log.Logger("entry-hard-fail"), + ) + if err == nil { + t.Fatal("runtime BeginAttempt error must HARD FAIL (not static fallback)") + } + if cleanup != nil { + t.Fatal("hard-fail must not return cleanup") + } + if !contains(err.Error(), "synthetic begin-attempt runtime failure") { + t.Fatalf("error must propagate underlying cause; got %v", err) + } +} + +// erroringEntryCoordinator implements roast.Coordinator with a +// synthetic BeginAttempt failure. Used to verify the HARD-FAIL +// branch of the executor-adapter entry helper. +type erroringEntryCoordinator struct { + err error +} + +func (e *erroringEntryCoordinator) BeginAttempt(_ attempt.AttemptContext) (roast.AttemptHandle, error) { + return roast.AttemptHandle{}, e.err +} +func (e *erroringEntryCoordinator) State(_ roast.AttemptHandle) (roast.AttemptState, error) { + return roast.AttemptStatePending, nil +} +func (e *erroringEntryCoordinator) SelectedCoordinator(_ roast.AttemptHandle) (group.MemberIndex, error) { + return 0, nil +} +func (e *erroringEntryCoordinator) RecordEvidence(_ roast.AttemptHandle, _ *roast.LocalEvidenceSnapshot) error { + return nil +} +func (e *erroringEntryCoordinator) AggregateBundle(_ roast.AttemptHandle) (*roast.TransitionMessage, error) { + return nil, nil +} +func (e *erroringEntryCoordinator) MarkSucceeded(_ roast.AttemptHandle) error { + return nil +} +func (e *erroringEntryCoordinator) VerifyBundle(_ roast.AttemptHandle, _ *roast.TransitionMessage) error { + return nil +} +func (e *erroringEntryCoordinator) NextAttempt( + _ roast.AttemptHandle, _ *roast.TransitionMessage, _ uint, _ []byte, +) (attempt.AttemptContext, error) { + return attempt.AttemptContext{}, nil +} + +func contains(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_test.go b/pkg/frost/signing/roast_retry_executor_entry_test.go new file mode 100644 index 0000000000..4554a7c24d --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_test.go @@ -0,0 +1,28 @@ +package signing + +import ( + "context" + "testing" + + "github.com/ipfs/go-log/v2" +) + +func TestAttemptRoastRetryOrchestrationFromRequest_DefaultBuildIsNoOp(t *testing.T) { + // In the default build, the helper is a permanent stub returning + // (nil, nil, nil) so the executor adapter behaves exactly as in + // Phase 5: no orchestration, no signature, no error, no cleanup deferred. + // + // The tagged-build test surface + // (roast_retry_executor_entry_frost_native_test.go) exercises + // the real branching. + _, cleanup, err := attemptRoastRetryOrchestrationFromRequest( + context.Background(), &NativeExecutionFFISigningRequest{SessionID: "x"}, + log.Logger("test"), + ) + if err != nil { + t.Fatalf("default-build helper must not return an error; got %v", err) + } + if cleanup != nil { + t.Fatal("default-build helper must not return a cleanup function") + } +} diff --git a/pkg/frost/signing/roast_retry_metrics.go b/pkg/frost/signing/roast_retry_metrics.go new file mode 100644 index 0000000000..d1312ab51e --- /dev/null +++ b/pkg/frost/signing/roast_retry_metrics.go @@ -0,0 +1,121 @@ +package signing + +import ( + "sync/atomic" + + "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// roastRetryEvidenceCounters holds cumulative event counts across +// the entire process lifetime. They are bumped whenever a +// metrics-emitting recorder records an event. Exposed to keep- +// core's clientinfo registry via RegisterRoastRetryMetrics, which +// operators invoke at process startup. +// +// The counters are intentionally process-wide rather than per- +// session: operators want to see "how many overflow events did +// the node observe today?" rather than "what was the count for +// the third attempt of session 0x1234?". Per-attempt detail is +// already visible in the TransitionMessage payload. +var ( + roastRetryOverflowEvents atomic.Uint64 + roastRetryRejectEvents atomic.Uint64 + roastRetryConflictEvents atomic.Uint64 +) + +// Application label prefix used by RegisterRoastRetryMetrics when +// registering with clientinfo.Registry.ObserveApplicationSource. +// The registry concatenates this with each per-source name, so the +// final metric labels look like "frost_roast_retry_overflow_events_total". +const roastRetryMetricsApplication = "frost_roast_retry" + +const ( + overflowEventsMetricName = "overflow_events_total" + rejectEventsMetricName = "reject_events_total" + conflictEventsMetricName = "conflict_events_total" +) + +// RegisterRoastRetryMetrics registers the cumulative ROAST-retry +// evidence counters with the supplied clientinfo registry. +// Operators call this from the node's startup sequence so the +// counters appear in the Prometheus scrape alongside the other +// keep-core metrics. +// +// The metrics are emitted in every build but only increment when +// the receive loops actually call into the metrics-emitting +// recorder, which happens only when the ROAST-retry registry is +// populated (i.e. the operator has opted in). In default builds +// the counters stay at zero. +func RegisterRoastRetryMetrics(registry *clientinfo.Registry) { + if registry == nil { + return + } + registry.ObserveApplicationSource( + roastRetryMetricsApplication, + map[string]clientinfo.Source{ + overflowEventsMetricName: func() float64 { + return float64(roastRetryOverflowEvents.Load()) + }, + rejectEventsMetricName: func() float64 { + return float64(roastRetryRejectEvents.Load()) + }, + conflictEventsMetricName: func() float64 { + return float64(roastRetryConflictEvents.Load()) + }, + }, + ) +} + +// metricsEmittingRecorder wraps an attempt.EvidenceRecorder with +// the process-wide cumulative counters declared above. Each +// Record*-class method bumps the matching counter and then +// delegates to the inner recorder so the per-attempt bounded +// snapshot still reflects the event for the NextAttempt policy. +// +// Use newMetricsEmittingRecorder to construct; do not instantiate +// directly. +type metricsEmittingRecorder struct { + inner attempt.EvidenceRecorder +} + +func newMetricsEmittingRecorder( + inner attempt.EvidenceRecorder, +) attempt.EvidenceRecorder { + if inner == nil { + return attempt.NoOpRecorder() + } + return &metricsEmittingRecorder{inner: inner} +} + +func (m *metricsEmittingRecorder) RecordOverflow(sender group.MemberIndex) { + roastRetryOverflowEvents.Add(1) + m.inner.RecordOverflow(sender) +} + +func (m *metricsEmittingRecorder) RecordReject( + sender group.MemberIndex, + reason string, +) { + roastRetryRejectEvents.Add(1) + m.inner.RecordReject(sender, reason) +} + +func (m *metricsEmittingRecorder) RecordConflict(sender group.MemberIndex) { + roastRetryConflictEvents.Add(1) + m.inner.RecordConflict(sender) +} + +func (m *metricsEmittingRecorder) Snapshot() attempt.Evidence { + return m.inner.Snapshot() +} + +// resetRoastRetryMetricsForTest clears the cumulative counters. +// Exposed only for the package's own tests; not a production +// helper. +func resetRoastRetryMetricsForTest() { + roastRetryOverflowEvents.Store(0) + roastRetryRejectEvents.Store(0) + roastRetryConflictEvents.Store(0) +} diff --git a/pkg/frost/signing/roast_retry_metrics_test.go b/pkg/frost/signing/roast_retry_metrics_test.go new file mode 100644 index 0000000000..fd5e015255 --- /dev/null +++ b/pkg/frost/signing/roast_retry_metrics_test.go @@ -0,0 +1,116 @@ +package signing + +import ( + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +func TestMetricsEmittingRecorder_IncrementsOnEachCategory(t *testing.T) { + resetRoastRetryMetricsForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + + rec := newMetricsEmittingRecorder(attempt.NewBoundedRecorder()) + rec.RecordOverflow(1) + rec.RecordOverflow(2) + rec.RecordReject(3, "validation_gate_rejected") + rec.RecordConflict(4) + rec.RecordConflict(5) + rec.RecordConflict(6) + + if got := roastRetryOverflowEvents.Load(); got != 2 { + t.Fatalf("overflow counter: got %d want 2", got) + } + if got := roastRetryRejectEvents.Load(); got != 1 { + t.Fatalf("reject counter: got %d want 1", got) + } + if got := roastRetryConflictEvents.Load(); got != 3 { + t.Fatalf("conflict counter: got %d want 3", got) + } +} + +func TestMetricsEmittingRecorder_DelegatesSnapshotToInner(t *testing.T) { + resetRoastRetryMetricsForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + + rec := newMetricsEmittingRecorder(attempt.NewBoundedRecorder()) + rec.RecordOverflow(7) + rec.RecordOverflow(7) + + snap := rec.Snapshot() + if snap.Overflows[7] != 2 { + t.Fatalf( + "inner snapshot must reflect events; got %d want 2", + snap.Overflows[7], + ) + } +} + +func TestMetricsEmittingRecorder_NilInnerFallsBackToNoOp(t *testing.T) { + resetRoastRetryMetricsForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + + rec := newMetricsEmittingRecorder(nil) + // Defensive guard: a nil inner recorder must produce a recorder + // that does not panic on Record* calls. The wrapper substitutes + // a NoOp inner. + rec.RecordOverflow(1) + rec.RecordReject(1, "x") + rec.RecordConflict(1) + // Counters STILL increment with the recommended call sites... + // wait, that's wrong. If inner is nil and we substitute NoOp, + // the wrapper is the NoOp recorder, no counters bumped. + if roastRetryOverflowEvents.Load() != 0 { + t.Fatal("nil inner -> NoOp; counters should stay at zero") + } +} + +func TestRoastRetryRecorderForCollect_WrapsBoundedWithMetricsWhenRegistered(t *testing.T) { + resetRoastRetryMetricsForTest() + ResetRoastRetryRegistrationForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + t.Cleanup(ResetRoastRetryRegistrationForTest) + + // Without registration, the recorder is NoOp -- recording does + // not bump the cumulative counters. + rec := roastRetryRecorderForCollect() + rec.RecordOverflow(1) + if roastRetryOverflowEvents.Load() != 0 { + t.Fatal("no registration -> NoOp recorder -> no counter bump") + } +} + +func TestMetricsEmittingRecorder_ConcurrentCountersAreRaceSafe(t *testing.T) { + resetRoastRetryMetricsForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + + rec := newMetricsEmittingRecorder(attempt.NewBoundedRecorder()) + const workers = 16 + const callsPerWorker = 100 + + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < callsPerWorker; j++ { + rec.RecordOverflow(1) + } + }() + } + wg.Wait() + + if got := roastRetryOverflowEvents.Load(); got != uint64(workers*callsPerWorker) { + t.Fatalf( + "concurrent counter: got %d want %d", + got, workers*callsPerWorker, + ) + } +} + +func TestRegisterRoastRetryMetrics_NilRegistryIsNoOp(t *testing.T) { + // Defensive: RegisterRoastRetryMetrics(nil) must not panic so + // optional integration paths can pass through nil. + RegisterRoastRetryMetrics(nil) +} diff --git a/pkg/frost/signing/roast_retry_orchestration.go b/pkg/frost/signing/roast_retry_orchestration.go new file mode 100644 index 0000000000..9268243995 --- /dev/null +++ b/pkg/frost/signing/roast_retry_orchestration.go @@ -0,0 +1,221 @@ +package signing + +// Static-vs-runtime error taxonomy (RFC-21 Phase 6 — Resolved Decision). +// +// The orchestration layer in this file participates in a load-bearing +// decision that prevents split-brain group fracture in the ROAST retry +// path. Errors returned through the orchestration boundary are +// classified into one of three categories, and the consumer (the +// signing-loop dispatcher) routes them accordingly: +// +// STATIC errors -> safe to fall back to the legacy retry path. +// Every honest signer observes the same node-local +// configuration state (registry population, build +// tags) at the same startup, so a fallback decision +// is deterministic across the group. No participant +// fork can arise from a static-error fallback. +// Sentinel: ErrNoRoastRetryCoordinatorRegistered. +// Detected via errors.Is in +// signing_loop_roast_dispatcher.go. +// +// RUNTIME errors -> NO FALLBACK, RETRY THE NEXT ATTEMPT. Any error +// that arises from per-attempt protocol state +// (BeginAttempt internals, AttemptContext binding +// mismatches, transition-bundle verification +// failures, etc.) can be observed by some +// participants and not others within the same +// attempt. Falling back to legacy under those +// conditions would leave some operators running the +// new code path and others running legacy on the +// same attempt -- the canonical definition of +// split-brain fracture. The orchestration layer +// therefore returns these as bare (non-sentinel) +// errors; the signingRetryLoop does NOT fall back to +// coarse, but it DOES retry on the next attempt +// because the fault may be transient and clear. +// +// TERMINAL errors -> ABORT THE RETRY LOOP. A STATIC condition that no +// future attempt can resolve, e.g. a partially- +// registered multi-seat operator: THIS seat has no +// ROAST coordinator while a sibling seat is ROAST- +// active, so this seat can neither drive ROAST nor +// safely fall back to legacy (that would fracture +// the attempt). (PR2b-2 member-keyed the handle +// binding, so a FULLY-registered multi-seat operator +// is no longer terminal -- it proceeds per-seat.) +// Unlike a RUNTIME error, retrying is FUTILE: every attempt +// re-derives the same static outcome, so the loop +// would spin until timeout AND synthesize garbage +// failed-attempt transitions (OnAttemptFailed). +// Coarse fallback is also unsafe (interactive<->coarse +// mixing fractures the group), so terminating is the +// only non-fracturing option. The orchestration layer +// wraps ErrTerminalSigningFailure; the signingRetryLoop +// matches it via errors.Is and exits immediately +// (return nil, err) BEFORE the retry/transition +// machinery. +// +// The classification is enforced at this file's boundary: any error +// surfaced from this package that is intended to permit fallback MUST +// be the ErrNoRoastRetryCoordinatorRegistered sentinel (or wrap it for +// errors.Is matching). Wrapping ANY runtime error in the sentinel is a +// safety regression that re-enables split-brain risk; PR reviewers +// should reject it. +// +// Background: this decision was redirected during Phase 5/6 review. +// The earlier design had Coordinator.BeginAttempt failures fall back to +// the legacy retry path on the assumption that BeginAttempt was a +// cheap idempotent setup. Review identified that BeginAttempt mutates +// per-attempt state (session bindings, evidence recorder) and can fail +// from races with concurrent receives or from peer-supplied protocol +// messages -- both of which produce non-deterministic per-participant +// outcomes. The taxonomy was tightened so only true configuration +// errors are fallback-eligible. + +import ( + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ErrNoRoastRetryCoordinatorRegistered is returned by +// BeginOrchestrationForSession when the package-level ROAST-retry +// registry has not been populated by a caller. The error is the +// "static configuration" class per the RFC-21 Phase-6 Resolved +// Decision on orchestration error taxonomy: it is safe to fall +// back to the legacy retry path because every honest signer +// observes the same registry state at the same node startup, so +// the fallback decision is deterministic across the group. +// +// Use errors.Is to detect. +var ErrNoRoastRetryCoordinatorRegistered = errors.New( + "roast orchestration: no coordinator registered", +) + +// ErrTerminalSigningFailure classifies an orchestration error as TERMINAL: a +// static condition no future attempt can resolve, so the signingRetryLoop must +// ABORT the loop (return nil, err) rather than retry the next attempt. It is the +// third disposition in the taxonomy above. Orchestration code wraps it +// (fmt.Errorf("%w: ...", ErrTerminalSigningFailure)) and the loop matches it via +// errors.Is. It is distinct from ErrNoRoastRetryCoordinatorRegistered (STATIC, +// coarse-fallback) and from bare RUNTIME errors (no fallback, but retried): a +// TERMINAL error is futile to retry and unsafe to coarse-fall-back, so the only +// non-fracturing disposition is to stop. +// +// Current sole producer: BeginOrchestrationForSession, for a partially-registered +// multi-seat operator -- this seat has no registered coordinator while a sibling +// seat is ROAST-active. (A fully-registered multi-seat operator is no longer +// terminal as of PR2b-2's member-keyed handle binding.) +// +// Use errors.Is to detect. +var ErrTerminalSigningFailure = errors.New( + "terminal signing failure", +) + +// BeginOrchestrationForSession encapsulates the per-session +// BeginAttempt + binding-population step the RFC-21 Phase 5 +// orchestration layer performs. Callers in the layer above the +// FROST signing primitive invoke it at session start; the returned +// cleanup function is the matching unbinding step the caller +// defers. +// +// Phase 5.2 ships the helper; Phase 6 wires production call sites +// to invoke it (and to feed the AttemptContext from the resolver +// adapter, etc.). +// +// When the ROAST-retry registry is empty (default build, no caller +// has registered a coordinator), the helper returns an error so +// the caller can fall back to legacy behaviour. The two-arg +// "shape" -- (handle, cleanup, error) -- forces the caller to +// handle the absence of a coordinator explicitly rather than +// silently dropping the orchestration. +// +// RFC-21 Phase 7.3 PR2b-1b retired the cleanup's transition-record +// production (the transition exchange is now the sole producer), so +// this no longer takes the DKG group public key. +func BeginOrchestrationForSession( + sessionID string, + member group.MemberIndex, + ctx attempt.AttemptContext, +) (roast.AttemptHandle, func(), error) { + if err := EnsureRoastRetryReadinessOptIn(); err != nil { + return roast.AttemptHandle{}, nil, fmt.Errorf( + "roast orchestration: %w", + err, + ) + } + // RFC-21 Phase 7.3 PR2b-1.5: mint the handle from THIS seat's coordinator, so a + // multi-seat operator's elected seat aggregates with its own binding. PR2b-2 + // member-keyed the handle binding below (SetCurrentAttemptHandleForSession), so a + // fully-registered multi-seat operator now proceeds per-seat with isolated + // bindings -- the PR2b-1.5 `count > 1` fail-closed guard that stood here is gone. + deps, ok := RegisteredRoastRetryCoordinatorForMember(member) + if !ok { + // THIS seat has no registered coordinator. Whether that means legacy fallback + // or fail-closed hinges on whether the PROCESS is ROAST-active, which is + // group-uniform (the selector uses any-entry RoastRetryActive()): + // - count == 0: no seat is registered anywhere, so ROAST is inactive + // process-wide -- a uniform condition every honest node decides + // identically -> safe legacy fallback (the static sentinel). + // - count > 0: a sibling seat IS ROAST-active (a partially-registered + // operator). Advertising the legacy fallback for THIS seat while the + // sibling drives bound ROAST would fracture the attempt (some seats bound, + // one seat legacy-shuffle) -> fail CLOSED (terminal). Member-keying the + // handle binding does NOTHING here: a seat with no coordinator cannot + // participate in ROAST regardless of key shape, so this fail-closed + // survives PR2b-2 (only the fully-registered multi-seat guard was retired). + if registeredRoastRetryMemberCount() == 0 { + return roast.AttemptHandle{}, nil, fmt.Errorf( + "%w: caller should fall back to legacy behaviour", + ErrNoRoastRetryCoordinatorRegistered, + ) + } + return roast.AttemptHandle{}, nil, fmt.Errorf( + "%w: seat %d has no registered coordinator while a sibling "+ + "seat is ROAST-active; fail closed", + ErrTerminalSigningFailure, + member, + ) + } + if deps.Coordinator == nil { + return roast.AttemptHandle{}, nil, fmt.Errorf( + "roast orchestration: registered RoastRetryDeps has nil Coordinator", + ) + } + handle, err := deps.Coordinator.BeginAttempt(ctx) + if err != nil { + return roast.AttemptHandle{}, nil, fmt.Errorf( + "roast orchestration: begin attempt for session %q: %w", + sessionID, + err, + ) + } + // RFC-21 Phase 7.3 PR2b-2: bind per (sessionID, member). A multi-seat + // operator's sibling seats each bind their own handle here, so neither + // overwrites the other and each seat's cleanup clears only its own binding. + SetCurrentAttemptHandleForSession(sessionID, member, handle, ctx) + cleanup := func() { + // RFC-21 Phase 7.3 PR2b-1b: the cleanup ONLY clears the per-attempt + // handle binding. It no longer produces a transition record -- the + // session-scoped transition exchange (the observe binding + forced- + // snapshot aggregation + bundle distribution) is now the SOLE producer, + // keyed by the observe handle. Producing here too would let this drive + // handle's (empty) aggregation write a SECOND, possibly divergent record + // for the same (sessionID, member) the exchange owns -- a fracture risk. + ClearCurrentAttemptHandleForSession(sessionID, member) + } + return handle, cleanup, nil +} + +// EndOrchestrationForSession is a convenience for callers that +// did not capture the cleanup function from +// BeginOrchestrationForSession (e.g. callers that pass session +// ownership across function boundaries). It is equivalent to +// invoking the cleanup function returned by Begin, so it clears the +// binding for this (session, member) seat. +func EndOrchestrationForSession(sessionID string, member group.MemberIndex) { + ClearCurrentAttemptHandleForSession(sessionID, member) +} diff --git a/pkg/frost/signing/roast_retry_orchestration_bundle_test.go b/pkg/frost/signing/roast_retry_orchestration_bundle_test.go new file mode 100644 index 0000000000..7e3f732c74 --- /dev/null +++ b/pkg/frost/signing/roast_retry_orchestration_bundle_test.go @@ -0,0 +1,83 @@ +//go:build frost_roast_retry + +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func cleanupTestContext(t *testing.T, members []group.MemberIndex) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "orchestration-cleanup-test", + "key-group", + []byte{0x01, 0x02, 0x03}, + [attempt.MessageDigestLength]byte{0xab}, + 0, + members, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + return ctx +} + +// TestCleanup_ClearsBindingAndProducesNoTransitionRecord pins the RFC-21 +// Phase 7.3 PR2b-1b producer retirement: the orchestration cleanup clears the +// per-attempt handle binding and, even on the elected coordinator with recorded +// evidence (the exact case the old producer aggregated), produces NO transition +// record -- the session-scoped transition exchange is the sole producer now, so +// a second drive-handle producer here would risk a divergent record. +func TestCleanup_ClearsBindingAndProducesNoTransitionRecord(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + + ctx := cleanupTestContext(t, []group.MemberIndex{1, 2, 3, 4, 5}) + + // Determine the elected coordinator and register a coordinator bound to it, + // so the retired producer's elected==member precondition WOULD have held. + scratch := roast.NewInMemoryCoordinator() + scratchHandle, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(scratchHandle) + coord := roast.NewInMemoryCoordinatorWithSigning( + elected, roast.NoOpSigner(), roast.NoOpSignatureVerifier(), + ) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: coord, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(elected), + }) + + const sessionID = "cleanup-no-record-session" + handle, cleanup, err := BeginOrchestrationForSession(sessionID, elected, ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + + // Seed the evidence the old producer would have aggregated. + snap := roast.NewLocalEvidenceSnapshot(elected, ctx.Hash(), attempt.Evidence{}) + snap.OperatorSignature = []byte{0x01} + if err := coord.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record evidence: %v", err) + } + + cleanup() + + if _, ok := RoastTransitionForSession(ctx.SessionID, elected); ok { + t.Fatal("cleanup must not produce a transition record (the exchange is the sole producer)") + } + if _, _, ok := currentAttemptHandleForCollect(sessionID, elected); ok { + t.Fatal("cleanup must clear the per-attempt handle binding") + } +} diff --git a/pkg/frost/signing/roast_retry_orchestration_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_orchestration_frost_roast_retry_test.go new file mode 100644 index 0000000000..0ef9b4bbec --- /dev/null +++ b/pkg/frost/signing/roast_retry_orchestration_frost_roast_retry_test.go @@ -0,0 +1,504 @@ +//go:build frost_roast_retry + +package signing + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newOrchestrationTestContext(t *testing.T) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "orchestration-session", + "key-group-orchestration", + []byte{0x01, 0x02}, + [attempt.MessageDigestLength]byte{0x77}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + return ctx +} + +func TestBeginOrchestrationForSession_HappyPath(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + ctx := newOrchestrationTestContext(t) + handle, cleanup, err := BeginOrchestrationForSession("session-A", 1, ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + + // Binding must exist under (session, member). + gotHandle, gotCtx, ok := currentAttemptHandleForCollect("session-A", 1) + if !ok { + t.Fatal("binding must exist after Begin") + } + if gotHandle != handle { + t.Fatal("binding handle mismatch") + } + if gotCtx.Hash() != ctx.Hash() { + t.Fatal("binding context mismatch") + } + + cleanup() + if _, _, ok := currentAttemptHandleForCollect("session-A", 1); ok { + t.Fatal("binding must be cleared after cleanup") + } +} + +func TestBeginOrchestrationForSession_ErrorsWhenRegistryEmpty(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Readiness env var is set; the registry is empty -- we expect + // the registry-empty error, not the env-var error. + _, _, err := BeginOrchestrationForSession("session-X", 1, newOrchestrationTestContext(t)) + if err == nil { + t.Fatal("expected error when registry is empty") + } + if !strings.Contains(err.Error(), "no coordinator registered") { + t.Fatalf("error must mention missing registration; got %v", err) + } + // Empty registry => count==0 => the STATIC legacy-fallback sentinel, NOT + // the terminal fail-closed (which is reserved for partial registration). + if !errors.Is(err, ErrNoRoastRetryCoordinatorRegistered) { + t.Fatalf("empty registry must return the static sentinel; got %v", err) + } + if errors.Is(err, ErrTerminalSigningFailure) { + t.Fatalf("empty registry must NOT be terminal; got %v", err) + } +} + +func TestBeginOrchestrationForSession_ErrorsWhenReadinessOptInUnset(t *testing.T) { + // Explicitly unset, in case the test runner inherits the env var + // from outside. + t.Setenv(RoastRetryReadinessOptInEnvVar, "") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Even with a registered coordinator, the readiness env var + // short-circuits orchestration. This is the load-bearing safety + // property: production builds with the frost_roast_retry tag + // still cannot enter the orchestration path without an explicit + // operator decision. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + _, _, err := BeginOrchestrationForSession("session-no-optin", 1, newOrchestrationTestContext(t)) + if !errors.Is(err, ErrRoastRetryReadinessOptOut) { + t.Fatalf("expected ErrRoastRetryReadinessOptOut, got %v", err) + } +} + +func TestBeginOrchestrationForSession_ErrorsWhenCoordinatorNil(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: nil, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + _, _, err := BeginOrchestrationForSession("session-Y", 1, newOrchestrationTestContext(t)) + if err == nil { + t.Fatal("expected error when Coordinator is nil") + } + if !strings.Contains(err.Error(), "nil Coordinator") { + t.Fatalf("error must mention nil coordinator; got %v", err) + } +} + +func TestBeginOrchestrationForSession_PropagatesBeginAttemptError(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // A coordinator whose BeginAttempt always fails. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: &erroringCoordinator{err: errors.New("synthetic begin failure")}, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + _, _, err := BeginOrchestrationForSession("session-Z", 1, newOrchestrationTestContext(t)) + if err == nil { + t.Fatal("expected error from coordinator") + } + if !strings.Contains(err.Error(), "synthetic begin failure") { + t.Fatalf("error must wrap underlying cause; got %v", err) + } +} + +// assertOrchestrationFailedClosed asserts err is a HARD fail-closed: non-nil, +// neither static-fallback sentinel, classified terminal, and that no session +// binding leaked for (sessionID, member). +func assertOrchestrationFailedClosed(t *testing.T, sessionID string, member group.MemberIndex, cleanup func(), err error) { + t.Helper() + if err == nil { + t.Fatal("expected a fail-closed error, got nil") + } + if errors.Is(err, ErrNoRoastRetryCoordinatorRegistered) { + t.Fatalf("must NOT return the legacy-fallback sentinel; got %v", err) + } + if errors.Is(err, ErrRoastRetryReadinessOptOut) { + t.Fatalf("must NOT return the readiness sentinel; got %v", err) + } + // Must be classified TERMINAL so the signingRetryLoop aborts instead of + // retrying the (static, never-resolving) fail-closed condition. + if !errors.Is(err, ErrTerminalSigningFailure) { + t.Fatalf("fail-closed must be classified terminal (ErrTerminalSigningFailure); got %v", err) + } + if cleanup != nil { + t.Fatal("a failed begin must not return a cleanup") + } + if _, _, ok := currentAttemptHandleForCollect(sessionID, member); ok { + t.Fatal("fail-closed must not create a session binding") + } +} + +// TestBeginOrchestrationForSession_FailsClosedPartialMultiSeat is the Codex +// re-review case: a multi-seat operator that has at least one seat registered but +// NOT this one. The member-aware lookup misses, and rather than returning the +// legacy-fallback sentinel (which would let this seat run coarse/legacy while the +// registered sibling drives bound ROAST -> fracture), Begin fails CLOSED. PR2b-2 +// keeps this fail-closed: member-keying the handle binding does nothing for a seat +// with no coordinator at all. +func TestBeginOrchestrationForSession_FailsClosedPartialMultiSeat(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Only seat 1 is registered; this Execute is for the unregistered seat 2. + RegisterRoastRetryCoordinatorForMember(1, RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinatorWithSigning(1, roast.NoOpSigner(), roast.NoOpSignatureVerifier()), + SelfMember: 1, + }) + + _, cleanup, err := BeginOrchestrationForSession("session-partial", 2, newOrchestrationTestContext(t)) + assertOrchestrationFailedClosed(t, "session-partial", 2, cleanup, err) + if !strings.Contains(err.Error(), "fail closed") { + t.Fatalf("error must explain the fail-closed; got %v", err) + } +} + +// TestBeginOrchestrationForSession_MultiSeatProceedsPerSeat asserts that PR2b-2 +// retired the fully-registered multi-seat fail-closed guard: with both local +// seats registered, EACH seat begins its own attempt and binds its OWN handle +// under (sessionID, member), so sibling seats stay isolated. The load-bearing +// isolation proof is that one seat's cleanup does NOT tear down the sibling's +// binding -- under the old sessionID-only key, seat 1's cleanup deleted the +// shared binding and seat 2 lost its hash enforcement. (The two handles are equal +// here because both coordinators mint id=1 for the same ctx; equality is expected, +// so survival-after-sibling-cleanup -- not handle distinctness -- is the proof. +// TestSessionHandleBinding_IsolatesByMember exercises distinct handles directly.) +func TestBeginOrchestrationForSession_MultiSeatProceedsPerSeat(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Both local seats registered -> multi-seat. + RegisterRoastRetryCoordinatorForMember(1, RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinatorWithSigning(1, roast.NoOpSigner(), roast.NoOpSignatureVerifier()), + SelfMember: 1, + }) + RegisterRoastRetryCoordinatorForMember(2, RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinatorWithSigning(2, roast.NoOpSigner(), roast.NoOpSignatureVerifier()), + SelfMember: 2, + }) + + ctx := newOrchestrationTestContext(t) + + handle1, cleanup1, err := BeginOrchestrationForSession("session-multiseat", 1, ctx) + if err != nil { + t.Fatalf("seat 1 begin must succeed (no longer fail-closed): %v", err) + } + if cleanup1 == nil { + t.Fatal("seat 1 cleanup must not be nil") + } + handle2, cleanup2, err := BeginOrchestrationForSession("session-multiseat", 2, ctx) + if err != nil { + t.Fatalf("seat 2 begin must succeed (no longer fail-closed): %v", err) + } + if cleanup2 == nil { + t.Fatal("seat 2 cleanup must not be nil") + } + + // Each seat reads back its own binding. + got1, _, ok := currentAttemptHandleForCollect("session-multiseat", 1) + if !ok { + t.Fatal("seat 1 binding must exist") + } + if got1 != handle1 { + t.Fatal("seat 1 must read back its own handle") + } + got2, _, ok := currentAttemptHandleForCollect("session-multiseat", 2) + if !ok { + t.Fatal("seat 2 binding must exist") + } + if got2 != handle2 { + t.Fatal("seat 2 must read back its own handle") + } + + // ISOLATION: seat 1's cleanup must clear ONLY seat 1's binding; seat 2's + // binding must survive (this is exactly the multi-seat bug being fixed). + cleanup1() + if _, _, ok := currentAttemptHandleForCollect("session-multiseat", 1); ok { + t.Fatal("seat 1 binding must be cleared after its own cleanup") + } + if _, _, ok := currentAttemptHandleForCollect("session-multiseat", 2); !ok { + t.Fatal("seat 2 binding must SURVIVE seat 1's cleanup (member isolation)") + } + cleanup2() + if _, _, ok := currentAttemptHandleForCollect("session-multiseat", 2); ok { + t.Fatal("seat 2 binding must be cleared after its own cleanup") + } +} + +// TestSessionHandleBinding_IsolatesByMember exercises the (sessionID, member) +// re-keying directly with DISTINCT handles: two contexts (differing only in +// attempt number) minted by one coordinator yield distinct handles, bound under +// the same session for two different members. Each member must read back its own +// handle, and clearing one member must leave the other intact. +func TestSessionHandleBinding_IsolatesByMember(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + coord := roast.NewInMemoryCoordinator() + ctxA := newOrchestrationTestContext(t) + ctxB, err := attempt.NewAttemptContext( + "orchestration-session", + "key-group-orchestration", + []byte{0x01, 0x02}, + [attempt.MessageDigestLength]byte{0x77}, + 1, // distinct attempt number -> distinct context hash -> distinct handle + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("ctxB: %v", err) + } + + handleA, err := coord.BeginAttempt(ctxA) + if err != nil { + t.Fatalf("beginA: %v", err) + } + handleB, err := coord.BeginAttempt(ctxB) + if err != nil { + t.Fatalf("beginB: %v", err) + } + if handleA == handleB { + t.Fatal("setup: the two handles must be distinct for a meaningful isolation test") + } + + SetCurrentAttemptHandleForSession("shared-session", 1, handleA, ctxA) + SetCurrentAttemptHandleForSession("shared-session", 2, handleB, ctxB) + + got1, gotCtx1, ok := currentAttemptHandleForCollect("shared-session", 1) + if !ok || got1 != handleA || gotCtx1.Hash() != ctxA.Hash() { + t.Fatalf("member 1 must read back its own (handle, ctx); ok=%v", ok) + } + got2, gotCtx2, ok := currentAttemptHandleForCollect("shared-session", 2) + if !ok || got2 != handleB || gotCtx2.Hash() != ctxB.Hash() { + t.Fatalf("member 2 must read back its own (handle, ctx); ok=%v", ok) + } + + // Clearing member 1 must not disturb member 2. + ClearCurrentAttemptHandleForSession("shared-session", 1) + if _, _, ok := currentAttemptHandleForCollect("shared-session", 1); ok { + t.Fatal("member 1 binding must be gone after clear") + } + if got, _, ok := currentAttemptHandleForCollect("shared-session", 2); !ok || got != handleB { + t.Fatal("member 2 binding must survive member 1's clear") + } +} + +func TestEndOrchestrationForSession_RemovesBinding(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContext(t) + SetCurrentAttemptHandleForSession("session-end", 3, roast.AttemptHandle{}, ctx) + + if _, _, ok := currentAttemptHandleForCollect("session-end", 3); !ok { + t.Fatal("setup: binding must exist") + } + EndOrchestrationForSession("session-end", 3) + if _, _, ok := currentAttemptHandleForCollect("session-end", 3); ok { + t.Fatal("binding must be removed after End") + } +} + +func TestEvictStaleSessionHandleBindings_RemovesOldEntries(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Two bindings with different ages. + ctx := newOrchestrationTestContext(t) + SetCurrentAttemptHandleForSession("session-old", 1, roast.AttemptHandle{}, ctx) + // Backdate by forcing the timestamp. + sessionAttemptBindingMu.Lock() + oldKey := sessionMemberKey{"session-old", 1} + b := sessionAttemptBindings[oldKey] + b.createdAt = time.Now().Add(-10 * time.Minute) + sessionAttemptBindings[oldKey] = b + sessionAttemptBindingMu.Unlock() + + SetCurrentAttemptHandleForSession("session-new", 1, roast.AttemptHandle{}, ctx) + + // Sweep with 5-minute TTL: old must be evicted, new must survive. + evicted := evictStaleSessionHandleBindings(5 * time.Minute) + if evicted != 1 { + t.Fatalf("expected 1 eviction, got %d", evicted) + } + if _, _, ok := currentAttemptHandleForCollect("session-old", 1); ok { + t.Fatal("session-old must be evicted") + } + if _, _, ok := currentAttemptHandleForCollect("session-new", 1); !ok { + t.Fatal("session-new must survive") + } +} + +func TestEvictStaleSessionHandleBindings_LeavesFreshEntries(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContext(t) + SetCurrentAttemptHandleForSession("session-fresh", 1, roast.AttemptHandle{}, ctx) + + // Sweep with the default 2-hour TTL: nothing should be evicted. + evicted := evictStaleSessionHandleBindings(SessionHandleBindingTTL) + if evicted != 0 { + t.Fatalf("expected 0 evictions for fresh binding, got %d", evicted) + } +} + +func TestSessionHandleBindingTTL_MatchesRFC(t *testing.T) { + if SessionHandleBindingTTL != 2*time.Hour { + t.Fatalf( + "RFC-21 specifies a 2-hour default TTL; constant is %s", + SessionHandleBindingTTL, + ) + } +} + +func TestStartSessionHandleSweeper_IsIdempotent(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + StartSessionHandleSweeper() + StartSessionHandleSweeper() + StartSessionHandleSweeper() + // sync.Once means only one goroutine started; we don't have a + // direct observable, but ResetSessionHandleRegistryForTest will + // close the stop channel and the goroutine will exit cleanly. + // If sync.Once were broken, double-close on the stop channel + // would panic during cleanup. +} + +func TestRegisterRoastRetryCoordinator_StartsSweeper(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + // Register again to verify sync.Once prevents a second + // sweeper. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 2, + }) + + // Reset should not panic (would panic on double-close if + // sync.Once failed). + ResetSessionHandleRegistryForTest() +} + +// erroringCoordinator returns a synthetic error from BeginAttempt. +// Other methods return zero values or nil; tests that need them +// should use a real coordinator. +type erroringCoordinator struct { + err error +} + +func (e *erroringCoordinator) BeginAttempt(_ attempt.AttemptContext) (roast.AttemptHandle, error) { + return roast.AttemptHandle{}, e.err +} +func (e *erroringCoordinator) State(_ roast.AttemptHandle) (roast.AttemptState, error) { + return roast.AttemptStatePending, nil +} +func (e *erroringCoordinator) SelectedCoordinator(_ roast.AttemptHandle) (group.MemberIndex, error) { + return 0, nil +} +func (e *erroringCoordinator) RecordEvidence(_ roast.AttemptHandle, _ *roast.LocalEvidenceSnapshot) error { + return nil +} +func (e *erroringCoordinator) AggregateBundle(_ roast.AttemptHandle) (*roast.TransitionMessage, error) { + return nil, nil +} +func (e *erroringCoordinator) MarkSucceeded(_ roast.AttemptHandle) error { + return nil +} +func (e *erroringCoordinator) VerifyBundle(_ roast.AttemptHandle, _ *roast.TransitionMessage) error { + return nil +} +func (e *erroringCoordinator) NextAttempt( + _ roast.AttemptHandle, _ *roast.TransitionMessage, _ uint, _ []byte, +) (attempt.AttemptContext, error) { + return attempt.AttemptContext{}, nil +} diff --git a/pkg/frost/signing/roast_retry_orchestration_test.go b/pkg/frost/signing/roast_retry_orchestration_test.go new file mode 100644 index 0000000000..f01315ee66 --- /dev/null +++ b/pkg/frost/signing/roast_retry_orchestration_test.go @@ -0,0 +1,37 @@ +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestBeginOrchestrationForSession_DefaultBuildReturnsError(t *testing.T) { + // In the default build, RegisteredRoastRetryCoordinator always + // returns (zero, false), so the orchestration helper must + // return an error directing the caller to fall back to legacy + // behaviour. This guarantees no production caller can + // accidentally "succeed" into orchestration when the build tag + // is off. + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + + ctx, err := attempt.NewAttemptContext( + "session-default-build", + "key-group", + []byte{0x01}, + [attempt.MessageDigestLength]byte{0x77}, + 0, + []group.MemberIndex{1, 2, 3}, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + + _, _, err = BeginOrchestrationForSession("session-default-build", 1, ctx) + if err == nil { + t.Fatal("default build must return error from BeginOrchestrationForSession") + } +} diff --git a/pkg/frost/signing/roast_retry_readiness.go b/pkg/frost/signing/roast_retry_readiness.go new file mode 100644 index 0000000000..75aed1ce69 --- /dev/null +++ b/pkg/frost/signing/roast_retry_readiness.go @@ -0,0 +1,110 @@ +package signing + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// RoastRetryReadinessOptInEnvVar is the environment variable name +// operators must set to "true" to opt in to RFC-21 ROAST retry +// activation. The variable is read per call -- not cached -- so an +// operator can flip it during a debugging session without +// restarting the node. +// +// Pattern matches the existing +// KEEP_CORE_FROST_TBTC_SIGNER_ACCEPT_SCAFFOLD_KEY_GROUP env var +// from PR #3960: a build tag enables the code path, an env var +// enables the wiring, both must agree for the feature to be live. +const RoastRetryReadinessOptInEnvVar = "KEEP_CORE_FROST_ROAST_RETRY_ENABLED" + +// ErrRoastRetryReadinessOptOut is the error +// EnsureRoastRetryReadinessOptIn returns when the env var is unset +// or set to anything other than "true". Use errors.Is to detect. +var ErrRoastRetryReadinessOptOut = errors.New( + "roast retry readiness: operator opt-in env var is not set to true", +) + +// EnsureRoastRetryReadinessOptIn reads the +// RoastRetryReadinessOptInEnvVar environment variable and returns +// nil if it is set to the string "true" (case-insensitive, +// whitespace-trimmed). Returns ErrRoastRetryReadinessOptOut +// otherwise. +// +// Callers in the orchestration layer invoke this before +// RegisterRoastRetryCoordinator so production builds with the +// frost_roast_retry build tag still refuse to wire orchestration +// without an explicit operator decision. +// +// The function is per-call (not cached) so operators can flip the +// env var dynamically during debugging. +func EnsureRoastRetryReadinessOptIn() error { + if !RoastRetryReadinessOptInEnabled() { + return fmt.Errorf( + "%w: set %s=true to enable", + ErrRoastRetryReadinessOptOut, + RoastRetryReadinessOptInEnvVar, + ) + } + return nil +} + +// RoastRetryReadinessOptInEnabled reports whether the readiness +// env var is currently set to "true". Cheap to call; use this when +// you need a boolean (e.g., to gate a log message) and +// EnsureRoastRetryReadinessOptIn when you need an error. +func RoastRetryReadinessOptInEnabled() bool { + value := strings.TrimSpace(os.Getenv(RoastRetryReadinessOptInEnvVar)) + return strings.EqualFold(value, "true") +} + +// RoastRetryActive reports whether ROAST retry orchestration is runtime-active: +// the readiness opt-in is set, a coordinator is registered, AND this build +// contains the transition producer (frost_native). It is the deterministic, +// process-level gate every honest node evaluates identically (env var + +// in-process registration + build), so the signing loop and the signing executor +// agree on whether to key the active attempt off the COMMITTED ROAST attempt +// index (roastAttemptNumber) rather than the block-paced attemptCounter -- RFC-21 +// Phase 7.3 PR2b-1b. The participant selector gates on the same predicate, so +// selection, observe, and the active signing context stay consistent. +// +// The producer requirement matters in a frost_roast_retry && !frost_native build: +// there the selector and the registry exist but nothing PRODUCES transition +// records, so without this check a retry would fail-close against a record that +// can never be created instead of using the uniform legacy shuffle (Codex P2-1). +// Always false in builds without the frost_roast_retry tag (the registration and +// producer default stubs both report unavailable). +// readinessAndProducerReady is the build+env prefix shared by RoastRetryActive and +// RoastRetryActiveForMember: the readiness opt-in is set AND the transition producer +// is built in (frost_native). Both gates additionally require a registered +// coordinator (any entry / the specific member's). +func readinessAndProducerReady() bool { + return RoastRetryReadinessOptInEnabled() && roastTransitionProducerAvailable() +} + +func RoastRetryActive() bool { + if !readinessAndProducerReady() { + return false + } + _, ok := RegisteredRoastRetryCoordinator() + return ok +} + +// RoastRetryActiveForMember reports whether ROAST retry is runtime-active for a +// SPECIFIC local seat: readiness opt-in AND the producer is built in AND THIS +// member has a coordinator registered. Member-aware paths (the per-seat signing +// loop, the per-member selector, observe, and the exchange) use it so a multi-seat +// operator activates ROAST per seat -- a seat with no registered coordinator stays +// on the legacy path rather than fail-closing. Always false in builds without the +// frost_roast_retry tag (the per-member registration default stub reports +// not-registered). RFC-21 Phase 7.3 PR2b-1.5. +func RoastRetryActiveForMember(member group.MemberIndex) bool { + if !readinessAndProducerReady() { + return false + } + _, ok := RegisteredRoastRetryCoordinatorForMember(member) + return ok +} diff --git a/pkg/frost/signing/roast_retry_readiness_test.go b/pkg/frost/signing/roast_retry_readiness_test.go new file mode 100644 index 0000000000..9eb0e82746 --- /dev/null +++ b/pkg/frost/signing/roast_retry_readiness_test.go @@ -0,0 +1,82 @@ +package signing + +import ( + "errors" + "strings" + "testing" +) + +func TestEnsureRoastRetryReadinessOptIn_AcceptsTrue(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + if err := EnsureRoastRetryReadinessOptIn(); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} + +func TestEnsureRoastRetryReadinessOptIn_AcceptsTrueCaseInsensitive(t *testing.T) { + cases := []string{"true", "True", "TRUE", "tRuE"} + for _, value := range cases { + t.Run(value, func(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, value) + if err := EnsureRoastRetryReadinessOptIn(); err != nil { + t.Fatalf("expected nil error for %q, got %v", value, err) + } + }) + } +} + +func TestEnsureRoastRetryReadinessOptIn_AcceptsTrimmedWhitespace(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, " true ") + if err := EnsureRoastRetryReadinessOptIn(); err != nil { + t.Fatalf("expected nil error for whitespace-padded 'true', got %v", err) + } +} + +func TestEnsureRoastRetryReadinessOptIn_RejectsUnset(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "") + err := EnsureRoastRetryReadinessOptIn() + if !errors.Is(err, ErrRoastRetryReadinessOptOut) { + t.Fatalf("expected ErrRoastRetryReadinessOptOut, got %v", err) + } + if !strings.Contains(err.Error(), RoastRetryReadinessOptInEnvVar) { + t.Fatalf( + "error must mention the env var name to guide operators; got %v", + err, + ) + } +} + +func TestEnsureRoastRetryReadinessOptIn_RejectsOtherValues(t *testing.T) { + cases := []string{"false", "1", "yes", "TRUE_", "tru", "anything"} + for _, value := range cases { + t.Run(value, func(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, value) + err := EnsureRoastRetryReadinessOptIn() + if !errors.Is(err, ErrRoastRetryReadinessOptOut) { + t.Fatalf("expected error for %q, got nil", value) + } + }) + } +} + +func TestRoastRetryReadinessOptInEnabled_MirrorsEnsureResult(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + if !RoastRetryReadinessOptInEnabled() { + t.Fatal("expected true when env var set to true") + } + t.Setenv(RoastRetryReadinessOptInEnvVar, "false") + if RoastRetryReadinessOptInEnabled() { + t.Fatal("expected false when env var set to false") + } +} + +func TestRoastRetryReadinessOptInEnvVar_MatchesRFC(t *testing.T) { + const expected = "KEEP_CORE_FROST_ROAST_RETRY_ENABLED" + if RoastRetryReadinessOptInEnvVar != expected { + t.Fatalf( + "env var name drifted: got %q want %q (must match RFC-21 Phase 5)", + RoastRetryReadinessOptInEnvVar, + expected, + ) + } +} diff --git a/pkg/frost/signing/roast_retry_recorder.go b/pkg/frost/signing/roast_retry_recorder.go new file mode 100644 index 0000000000..4bc2e292d7 --- /dev/null +++ b/pkg/frost/signing/roast_retry_recorder.go @@ -0,0 +1,38 @@ +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// roastRetryRecorderForCollect returns the EvidenceRecorder a FROST +// receive loop should use for its current call. +// +// When the package-level ROAST-retry registry is empty (default +// build, or no caller has invoked RegisterRoastRetryCoordinator), +// the receive loops fall back to attempt.NoOpRecorder() so receive +// semantics match Phase 2 exactly: overflow events are discarded +// without observable effect. +// +// When the registry has a coordinator, the function returns a fresh +// attempt.NewBoundedRecorder(). Each call returns a NEW recorder so +// per-collect evidence does not leak across calls. The caller is +// responsible for capturing the returned recorder if it intends to +// inspect Snapshot() at end-of-collect; in Phase 4.2 we only wire +// the call sites to use the registry. PR 4.3 captures the recorder +// reference and submits its snapshot via Coordinator.RecordEvidence. +// +// This helper is intentionally not build-tagged: it delegates to +// RegisteredRoastRetryCoordinator (which IS build-tagged via the +// roast_retry_registration_* files), so the default-build path +// always sees an empty registry and returns NoOp without paying any +// coordinator-construction cost. +func roastRetryRecorderForCollect() attempt.EvidenceRecorder { + if _, ok := RegisteredRoastRetryCoordinator(); !ok { + return attempt.NoOpRecorder() + } + // Wrap the bounded recorder with the metrics-emitting + // decorator so RecordOverflow/Reject/Conflict bump the + // process-wide cumulative counters that + // RegisterRoastRetryMetrics exposes to clientinfo. + return newMetricsEmittingRecorder(attempt.NewBoundedRecorder()) +} diff --git a/pkg/frost/signing/roast_retry_recorder_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_recorder_frost_roast_retry_test.go new file mode 100644 index 0000000000..96d5ab6a4e --- /dev/null +++ b/pkg/frost/signing/roast_retry_recorder_frost_roast_retry_test.go @@ -0,0 +1,56 @@ +//go:build frost_roast_retry + +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestRoastRetryRecorderForCollect_RecordsOverflowWhenRegistered(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + rec := roastRetryRecorderForCollect() + const sender group.MemberIndex = 3 + rec.RecordOverflow(sender) + rec.RecordOverflow(sender) + snap := rec.Snapshot() + if got := snap.Overflows[sender]; got != 2 { + t.Fatalf( + "expected bounded recorder to accumulate overflows; got %d for sender %d", + got, sender, + ) + } +} + +func TestRoastRetryRecorderForCollect_FallsBackToNoOpAfterReset(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + ResetRoastRetryRegistrationForTest() + + rec := roastRetryRecorderForCollect() + rec.RecordOverflow(5) + if got := rec.Snapshot().Overflows[5]; got != 0 { + t.Fatalf( + "after reset the recorder must be NoOp; got count %d", + got, + ) + } +} diff --git a/pkg/frost/signing/roast_retry_recorder_test.go b/pkg/frost/signing/roast_retry_recorder_test.go new file mode 100644 index 0000000000..cd6fd04089 --- /dev/null +++ b/pkg/frost/signing/roast_retry_recorder_test.go @@ -0,0 +1,76 @@ +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestRoastRetryRecorderForCollect_NoOpWhenRegistryEmpty(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + rec := roastRetryRecorderForCollect() + // Record an overflow. NoOp recorders must show zero in their + // snapshot regardless of input. + rec.RecordOverflow(group.MemberIndex(1)) + rec.RecordOverflow(group.MemberIndex(2)) + snap := rec.Snapshot() + if len(snap.Overflows) != 0 { + t.Fatalf( + "expected NoOp recorder when registry empty; got %d overflow entries", + len(snap.Overflows), + ) + } +} + +func TestRoastRetryRecorderForCollect_BoundedWhenRegistryPopulated(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + // In the default build, RegisterRoastRetryCoordinator is a + // no-op stub; the registry stays empty and this test asserts + // the same NoOp behaviour as the previous test. The tagged + // build (roast_retry_recorder_frost_roast_retry_test.go) is + // where we assert real BoundedRecorder allocation. + RegisterRoastRetryCoordinator(RoastRetryDeps{SelfMember: 1}) + + rec := roastRetryRecorderForCollect() + if rec == nil { + t.Fatal("recorder must never be nil") + } + // We don't assert the *type* of recorder here because tagged + // vs default builds will return different concrete types; the + // observable contract is that Snapshot() always works. + _ = rec.Snapshot() +} + +func TestRoastRetryRecorderForCollect_NewRecorderEachCall(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + // Even in the default build, the helper returns a recorder + // instance per call. We assert that the snapshot for the first + // call does not leak into the second. + a := roastRetryRecorderForCollect() + a.RecordOverflow(group.MemberIndex(1)) + b := roastRetryRecorderForCollect() + bSnap := b.Snapshot() + if got := bSnap.Overflows[1]; got != 0 { + t.Fatalf( + "second recorder must not share state with first; got overflow count %d for sender 1", + got, + ) + } + // Sanity-check: in the NoOp path, even the first recorder's + // snapshot is empty. + if got := a.Snapshot().Overflows[1]; got != 0 { + // NoOp path: must be 0. + // Tagged path: also 0 (we only registered above; this test + // runs default-build). + _ = got + } + // Silence unused. + _ = attempt.NoOpRecorder() +} diff --git a/pkg/frost/signing/roast_retry_registration_default_build.go b/pkg/frost/signing/roast_retry_registration_default_build.go new file mode 100644 index 0000000000..87ca3e2ae0 --- /dev/null +++ b/pkg/frost/signing/roast_retry_registration_default_build.go @@ -0,0 +1,75 @@ +//go:build !frost_roast_retry + +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// RoastRetryDeps bundles the per-process dependencies the FROST +// receive loops need to participate in RFC-21 Phase-4 coordinator- +// driven evidence flow: +// +// - Coordinator drives BeginAttempt / RecordEvidence / AggregateBundle +// / VerifyBundle / NextAttempt. +// - Signer produces operator-key signatures over canonical +// snapshot and bundle bytes. +// - Verifier validates signatures on inbound snapshots and bundles. +// +// The type is exported in every build so callers can construct it +// without conditional compilation. In the default build the registry +// is a permanent no-op stub: the receive loops cannot find a +// registered coordinator and therefore fall back to the Phase-2 +// `attempt.NoOpRecorder()` behaviour, preserving exact pre-RFC-21 +// receive semantics. +// +// The real registry behind the `frost_roast_retry` build tag is in +// roast_retry_registration_frost_roast_retry.go. +type RoastRetryDeps struct { + Coordinator roast.Coordinator + Signer roast.Signer + Verifier roast.SignatureVerifier + // SelfMember is the local node's member index. The Coordinator + // is already bound to this value via NewInMemoryCoordinatorWithSigning, + // but receivers need it independently so they can correlate + // AttemptHandles with their own snapshots in later Phase-4 PRs. + SelfMember uint32 +} + +// RegisterRoastRetryCoordinator is a no-op in the default build. +// Callers in production code may invoke it unconditionally; the +// registration only takes effect when the `frost_roast_retry` build +// tag is active. +func RegisterRoastRetryCoordinator(_ RoastRetryDeps) {} + +// RegisterRoastRetryCoordinatorForMember is a no-op in the default +// build. Production multi-seat wiring may invoke it unconditionally; +// the per-member registration only takes effect under the +// `frost_roast_retry` build tag. +func RegisterRoastRetryCoordinatorForMember(_ group.MemberIndex, _ RoastRetryDeps) {} + +// RegisteredRoastRetryCoordinatorForMember returns (zero, false) in +// the default build: no ROAST-retry plumbing is active for any seat, +// so member-aware receivers use the Phase-2 NoOp fallback. +func RegisteredRoastRetryCoordinatorForMember(_ group.MemberIndex) (RoastRetryDeps, bool) { + return RoastRetryDeps{}, false +} + +// RegisteredRoastRetryCoordinator returns (zero, false) in the +// default build, signalling to receivers that ROAST-retry plumbing +// is not active and they should continue to use the Phase-2 +// NoOpRecorder fallback. +func RegisteredRoastRetryCoordinator() (RoastRetryDeps, bool) { + return RoastRetryDeps{}, false +} + +// registeredRoastRetryMemberCount returns 0 in the default build: no seats are +// ever registered, so BeginOrchestrationForSession's partial-registration +// fail-closed branch (count>0) is never reached here. +func registeredRoastRetryMemberCount() int { return 0 } + +// ResetRoastRetryRegistrationForTest is a no-op in the default +// build. Exposed so tests can call it unconditionally regardless of +// which build is active. +func ResetRoastRetryRegistrationForTest() {} diff --git a/pkg/frost/signing/roast_retry_registration_default_build_test.go b/pkg/frost/signing/roast_retry_registration_default_build_test.go new file mode 100644 index 0000000000..91b0135ba4 --- /dev/null +++ b/pkg/frost/signing/roast_retry_registration_default_build_test.go @@ -0,0 +1,27 @@ +//go:build !frost_roast_retry + +package signing + +import "testing" + +func TestRoastRetryRegistration_DefaultBuildIsStub(t *testing.T) { + // Register a non-zero dependency set. Because the default build + // is a no-op stub, the registry must remain empty. + deps := RoastRetryDeps{SelfMember: 7} + RegisterRoastRetryCoordinator(deps) + got, ok := RegisteredRoastRetryCoordinator() + if ok { + t.Fatalf("default build must report not-registered; got ok=true, deps=%+v", got) + } + if got != (RoastRetryDeps{}) { + t.Fatalf("default build must return zero value; got %+v", got) + } +} + +func TestRoastRetryRegistration_DefaultBuildResetIsNoOp(t *testing.T) { + // Reset should not panic even though there is no real state. + ResetRoastRetryRegistrationForTest() + if _, ok := RegisteredRoastRetryCoordinator(); ok { + t.Fatal("default build registry should remain empty after reset") + } +} diff --git a/pkg/frost/signing/roast_retry_registration_frost_roast_retry.go b/pkg/frost/signing/roast_retry_registration_frost_roast_retry.go new file mode 100644 index 0000000000..3bacb67c24 --- /dev/null +++ b/pkg/frost/signing/roast_retry_registration_frost_roast_retry.go @@ -0,0 +1,120 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// RoastRetryDeps bundles the per-process dependencies the FROST +// receive loops need under the frost_roast_retry build tag. See the +// default-build file for the doc contract; this declaration is the +// real one used when the build tag is active. +type RoastRetryDeps struct { + Coordinator roast.Coordinator + Signer roast.Signer + Verifier roast.SignatureVerifier + SelfMember uint32 +} + +// roastRetryRegistrationByMember holds one set of ROAST-retry dependencies PER +// local seat (member). A multi-seat operator registers one entry per member, each +// with a Coordinator bound to THAT member (deps.SelfMember == member) so whichever +// local seat is the elected ROAST coordinator for an attempt can aggregate; the +// Signer and Verifier are the shared operator key. A single-seat node has one +// entry. A later registration for the same member replaces the earlier one +// (runtime reconfiguration is intentional). RFC-21 Phase 7.3 PR2b-1.5. +var ( + roastRetryRegistrationMu sync.RWMutex + roastRetryRegistrationByMember = map[group.MemberIndex]RoastRetryDeps{} +) + +// RegisterRoastRetryCoordinatorForMember stores the ROAST-retry dependencies for +// one local seat. deps.SelfMember MUST equal member: the Coordinator is bound to +// deps.SelfMember at construction, so registering it under a different member +// would let AggregateBundle run as the wrong seat. A mismatch is rejected with no +// registration (the seat stays ROAST-inactive -> legacy) rather than silently +// mis-binding. +// +// As a side effect, the first registration starts the session-handle sweeper +// goroutine that evicts orphaned bindings (defence-in-depth backstop); subsequent +// registrations do not restart it. +func RegisterRoastRetryCoordinatorForMember(member group.MemberIndex, deps RoastRetryDeps) { + if member == 0 || deps.SelfMember != uint32(member) { + // Member indices are 1-based; a coordinator bound to selfMember 0 is the + // "disabled" sentinel that NEVER aggregates (coordinator_state.go), so + // registering under member 0 -- or under any member that disagrees with + // deps.SelfMember -- would silently mis-bind. REMOVE any existing entry for + // this member so a bad re-registration deactivates the seat (fail-safe to + // legacy) rather than leaving STALE deps active (Codex P2-2); member 0 never + // has an entry, so the delete is a no-op there. + roastRetryRegistrationMu.Lock() + delete(roastRetryRegistrationByMember, member) + roastRetryRegistrationMu.Unlock() + return + } + roastRetryRegistrationMu.Lock() + roastRetryRegistrationByMember[member] = deps + roastRetryRegistrationMu.Unlock() + StartSessionHandleSweeper() +} + +// RegisteredRoastRetryCoordinatorForMember returns the dependencies registered for +// the given local seat and true, or the zero value and false if that seat has +// none. Member-aware receive/selection paths use this so a multi-seat operator's +// elected seat aggregates with its OWN coordinator. +func RegisteredRoastRetryCoordinatorForMember(member group.MemberIndex) (RoastRetryDeps, bool) { + roastRetryRegistrationMu.RLock() + defer roastRetryRegistrationMu.RUnlock() + deps, ok := roastRetryRegistrationByMember[member] + return deps, ok +} + +// RegisterRoastRetryCoordinator is the legacy single-seat registration: it +// registers deps under deps.SelfMember. Kept for single-seat wiring and the +// existing test callers; production multi-seat wiring calls +// RegisterRoastRetryCoordinatorForMember once per local seat. +func RegisterRoastRetryCoordinator(deps RoastRetryDeps) { + RegisterRoastRetryCoordinatorForMember(group.MemberIndex(deps.SelfMember), deps) +} + +// RegisteredRoastRetryCoordinator is the legacy single-seat lookup: it returns +// SOME registered entry and true, or the zero value and false if none. For a +// single-seat node it returns that node's only entry; under multi-seat it returns +// an ARBITRARY entry (map order) and must not be used by member-aware code -- +// those paths use RegisteredRoastRetryCoordinatorForMember. Kept for the readiness +// gate's any-registered check and single-seat/test compatibility. +func RegisteredRoastRetryCoordinator() (RoastRetryDeps, bool) { + roastRetryRegistrationMu.RLock() + defer roastRetryRegistrationMu.RUnlock() + for _, deps := range roastRetryRegistrationByMember { + return deps, true + } + return RoastRetryDeps{}, false +} + +// registeredRoastRetryMemberCount returns how many local seats currently have a +// coordinator registered. BeginOrchestrationForSession uses it for the one +// distinction that depends on process-wide ROAST activation: when THIS seat has no +// coordinator, count==0 means ROAST is inactive everywhere (safe legacy fallback) +// while count>0 means a sibling seat IS ROAST-active, so this unregistered seat +// must fail closed rather than fracture the attempt. (RFC-21 Phase 7.3 PR2b-2 +// retired the former submitSnapshotIfActive multi-seat no-op that also used this; +// the evidence path is now member-keyed and isolates seats directly.) +func registeredRoastRetryMemberCount() int { + roastRetryRegistrationMu.RLock() + defer roastRetryRegistrationMu.RUnlock() + return len(roastRetryRegistrationByMember) +} + +// ResetRoastRetryRegistrationForTest clears the registry. Exposed so tests in this +// and downstream packages can reset between cases without leaking state. Not +// intended for production code paths. +func ResetRoastRetryRegistrationForTest() { + roastRetryRegistrationMu.Lock() + defer roastRetryRegistrationMu.Unlock() + roastRetryRegistrationByMember = map[group.MemberIndex]RoastRetryDeps{} +} diff --git a/pkg/frost/signing/roast_retry_registration_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_registration_frost_roast_retry_test.go new file mode 100644 index 0000000000..8366bff14f --- /dev/null +++ b/pkg/frost/signing/roast_retry_registration_frost_roast_retry_test.go @@ -0,0 +1,231 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" +) + +func TestRoastRetryRegistration_TaggedBuildRoundTrip(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + if _, ok := RegisteredRoastRetryCoordinator(); ok { + t.Fatal("registry must start empty") + } + + coord := roast.NewInMemoryCoordinator() + deps := RoastRetryDeps{ + Coordinator: coord, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 7, + } + RegisterRoastRetryCoordinator(deps) + + got, ok := RegisteredRoastRetryCoordinator() + if !ok { + t.Fatal("expected ok=true after register") + } + if got.SelfMember != 7 { + t.Fatalf("self member mismatch: got %d want 7", got.SelfMember) + } + if got.Coordinator == nil { + t.Fatal("coordinator must round-trip") + } +} + +// TestRoastRetryActive_GatesOnReadinessAndRegistration asserts RoastRetryActive +// is true only when BOTH the readiness opt-in is set AND a coordinator is +// registered -- the deterministic group-wide gate the signing loop uses to decide +// whether to key the active attempt off the committed roast number. +func TestRoastRetryActive_GatesOnReadinessAndRegistration(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + // Readiness off -> inactive regardless of registration. + t.Setenv(RoastRetryReadinessOptInEnvVar, "false") + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + SelfMember: 1, + }) + if RoastRetryActive() { + t.Fatal("readiness off must yield inactive even with a coordinator") + } + + // Readiness on but no coordinator -> inactive. + ResetRoastRetryRegistrationForTest() + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + if RoastRetryActive() { + t.Fatal("readiness on without a coordinator must yield inactive") + } + + // Readiness on AND a coordinator -> active IFF a transition producer is built in + // (frost_native). A frost_roast_retry && !frost_native build has no producer, so + // ROAST stays inactive (legacy) even with readiness + a coordinator -- the + // build-config gate from Codex P2-1. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + SelfMember: 1, + }) + if RoastRetryActive() != roastTransitionProducerAvailable() { + t.Fatalf( + "readiness + coordinator: RoastRetryActive must equal producer availability (%v); got %v", + roastTransitionProducerAvailable(), RoastRetryActive(), + ) + } +} + +// TestRoastRetryActiveForMember_GatesPerMember asserts per-member activation: a +// seat with a registered coordinator is active (given readiness + a producer); a +// seat WITHOUT one is inactive even when a sibling seat is registered. +func TestRoastRetryActiveForMember_GatesPerMember(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinatorForMember(1, RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinatorWithSigning(1, roast.NoOpSigner(), roast.NoOpSignatureVerifier()), + SelfMember: 1, + }) + + // Member 1 active iff a producer is built in; member 2 (unregistered) never. + if RoastRetryActiveForMember(1) != roastTransitionProducerAvailable() { + t.Fatalf("member 1: active must equal producer availability (%v); got %v", + roastTransitionProducerAvailable(), RoastRetryActiveForMember(1)) + } + if RoastRetryActiveForMember(2) { + t.Fatal("member 2 (unregistered) must be inactive even with a sibling registered") + } + + t.Setenv(RoastRetryReadinessOptInEnvVar, "false") + if RoastRetryActiveForMember(1) { + t.Fatal("readiness off must yield inactive even for a registered member") + } +} + +// TestRoastRetryRegistration_PerMemberOverwriteAndCoexist asserts the per-member +// registry semantics (PR2b-1.5): registering the SAME member twice overwrites that +// member's entry, while DIFFERENT members coexist (a multi-seat operator registers +// one coordinator per local seat). +func TestRoastRetryRegistration_PerMemberOverwriteAndCoexist(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + coord1a := roast.NewInMemoryCoordinatorWithSigning(1, roast.NoOpSigner(), roast.NoOpSignatureVerifier()) + coord1b := roast.NewInMemoryCoordinatorWithSigning(1, roast.NoOpSigner(), roast.NoOpSignatureVerifier()) + coord2 := roast.NewInMemoryCoordinatorWithSigning(2, roast.NoOpSigner(), roast.NoOpSignatureVerifier()) + + RegisterRoastRetryCoordinatorForMember(1, RoastRetryDeps{Coordinator: coord1a, SelfMember: 1}) + RegisterRoastRetryCoordinatorForMember(2, RoastRetryDeps{Coordinator: coord2, SelfMember: 2}) + RegisterRoastRetryCoordinatorForMember(1, RoastRetryDeps{Coordinator: coord1b, SelfMember: 1}) // overwrite 1 + + got1, ok := RegisteredRoastRetryCoordinatorForMember(1) + if !ok || got1.Coordinator != coord1b { + t.Fatalf("member 1 must hold the later (overwriting) coordinator; ok=%v", ok) + } + got2, ok := RegisteredRoastRetryCoordinatorForMember(2) + if !ok || got2.Coordinator != coord2 { + t.Fatalf("member 2 must coexist with member 1; ok=%v", ok) + } +} + +// TestRoastRetryRegistration_RejectsSelfMemberMismatch asserts a coordinator +// registered under a member that does not match deps.SelfMember is rejected -- the +// coordinator is bound to deps.SelfMember at construction, so registering it under +// a different member would let it aggregate as the wrong seat. +func TestRoastRetryRegistration_RejectsSelfMemberMismatch(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinatorForMember(5, RoastRetryDeps{SelfMember: 3}) + if _, ok := RegisteredRoastRetryCoordinatorForMember(5); ok { + t.Fatal("a SelfMember/member mismatch must not register") + } +} + +// TestRoastRetryRegistration_RejectDropsExistingEntry asserts a rejected +// re-registration (member 0 or a SelfMember mismatch) REMOVES any existing entry, +// so a bad reconfiguration deactivates the seat (fail-safe to legacy) rather than +// leaving stale deps active (Codex P2-2). +func TestRoastRetryRegistration_RejectDropsExistingEntry(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinatorForMember(1, RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinatorWithSigning(1, roast.NoOpSigner(), roast.NoOpSignatureVerifier()), + SelfMember: 1, + }) + if _, ok := RegisteredRoastRetryCoordinatorForMember(1); !ok { + t.Fatal("member 1 must be registered after a valid registration") + } + + // A later mis-registration for member 1 (deps bound to member 2) must DROP the + // existing entry, not silently keep the stale one. + RegisterRoastRetryCoordinatorForMember(1, RoastRetryDeps{SelfMember: 2}) + if _, ok := RegisteredRoastRetryCoordinatorForMember(1); ok { + t.Fatal("a rejected re-registration must drop the existing entry (fail-safe to inactive)") + } +} + +// TestRoastRetryRegistration_LegacyWrapperRegistersUnderSelfMember asserts the +// legacy single-arg RegisterRoastRetryCoordinator registers under deps.SelfMember, +// so existing single-seat callers + RegisteredRoastRetryCoordinator round-trip. +func TestRoastRetryRegistration_LegacyWrapperRegistersUnderSelfMember(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{SelfMember: 4}) + if _, ok := RegisteredRoastRetryCoordinatorForMember(4); !ok { + t.Fatal("legacy register must place the entry under deps.SelfMember (4)") + } + if got, ok := RegisteredRoastRetryCoordinator(); !ok || got.SelfMember != 4 { + t.Fatalf("legacy lookup must round-trip the single entry; got %d ok=%v", got.SelfMember, ok) + } +} + +func TestRoastRetryRegistration_ResetClearsRegistry(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{SelfMember: 1}) + ResetRoastRetryRegistrationForTest() + if _, ok := RegisteredRoastRetryCoordinator(); ok { + t.Fatal("registry must be empty after reset") + } +} + +func TestRoastRetryRegistration_ConcurrentRegisterAndLookupIsRaceSafe(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + var wg sync.WaitGroup + const registers = 32 + const lookups = 64 + for i := 0; i < registers; i++ { + wg.Add(1) + i := i + go func() { + defer wg.Done() + RegisterRoastRetryCoordinator(RoastRetryDeps{SelfMember: uint32(i + 1)}) + }() + } + for i := 0; i < lookups; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = RegisteredRoastRetryCoordinator() + }() + } + wg.Wait() + + // We don't assert a specific SelfMember -- registers race against + // each other and any of them can land last. We assert only that + // SOME registration succeeded. + if _, ok := RegisteredRoastRetryCoordinator(); !ok { + t.Fatal("expected at least one register to take effect") + } +} diff --git a/pkg/frost/signing/roast_retry_submit.go b/pkg/frost/signing/roast_retry_submit.go new file mode 100644 index 0000000000..bdd659f9c9 --- /dev/null +++ b/pkg/frost/signing/roast_retry_submit.go @@ -0,0 +1,68 @@ +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// submitSnapshotIfActive is invoked at end-of-collect to capture the receive +// loop's accumulated evidence for the ROAST blame pipeline. member is the local +// seat whose receive loop is submitting (request.MemberIndex). The path is fully +// member-aware (RFC-21 Phase 7.3 PR2b-2): a multi-seat operator's sibling seats +// each capture their own evidence against their own attempt binding, so they never +// mis-attribute or collide. +// +// RFC-21 Phase 7.3 PR2b-2 step 2 (the blame bridge): the captured evidence is +// STASHED keyed by the attempt's (RoastSessionID, member, attemptHash), NOT +// recorded against the drive handle. The drive handle is never aggregated -- the +// transition exchange is the sole bundle producer and aggregates the OBSERVE +// handle -- so a RecordEvidence here was a write-only dead end. Stashing instead +// lets the exchange's BroadcastForcedSnapshot build + sign ONE snapshot carrying +// this evidence, so the elected coordinator's AggregateBundle includes it and +// NextAttempt's f+1 accuser tally can finally fire. +// +// The function is a no-op when any of the following holds: +// +// - no session-handle binding exists for (sessionID, member): the default build +// (where currentAttemptHandleForCollect always returns ok=false), or a state +// where the orchestration layer that calls SetCurrentAttemptHandleForSession +// has not run; +// - the recorder is nil / a NoOp (no events were captured); +// - the captured evidence is empty across all three categories: the exchange +// still broadcasts an empty proof-of-attendance snapshot for the attempt, so +// skipping the stash here does not silence-park the seat. +// +// Capturing must never break the receive loop's primary signing behaviour, so the +// function returns silently on every skip condition. +func submitSnapshotIfActive( + sessionID string, + member group.MemberIndex, + recorder attempt.EvidenceRecorder, +) { + if recorder == nil { + return + } + // The drive binding (set by BeginOrchestrationForSession) signals this seat is + // driving a ROAST attempt and carries its AttemptContext. ctx.SessionID is the + // STABLE RoastSessionID and ctx.Hash() the attempt hash -- the + // namespace-independent coordinate the transition exchange keys its observe + // binding (and this stash) by, so the broadcast resolves the same entry. + _, ctx, ok := currentAttemptHandleForCollect(sessionID, member) + if !ok { + return + } + evidence := recorder.Snapshot() + if len(evidence.Overflows) == 0 && + len(evidence.Rejects) == 0 && + len(evidence.Conflicts) == 0 { + // Truly nothing observed worth carrying. The emptiness test MUST consider + // all three categories, not just overflows: a validation-blamable Reject + // (e.g. an attempt-context-hash mismatch) or a first-write-wins Conflict + // populates Rejects/Conflicts WITHOUT any Overflow, and NextAttempt's + // exclusion path consumes snapshot.Rejects (next_attempt.go). Dropping a + // reject/conflict-only snapshot here would silently starve the blame + // pipeline of exactly the validation evidence it needs. + return + } + stashPendingEvidence(ctx.SessionID, member, ctx.Hash(), evidence) +} diff --git a/pkg/frost/signing/roast_retry_submit_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_submit_frost_roast_retry_test.go new file mode 100644 index 0000000000..9dd61df106 --- /dev/null +++ b/pkg/frost/signing/roast_retry_submit_frost_roast_retry_test.go @@ -0,0 +1,238 @@ +//go:build frost_roast_retry + +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newTestContextForSubmit(t *testing.T, sessionID string) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + sessionID, + "key-group-submit", + []byte{0xAA}, + [attempt.MessageDigestLength]byte{0x42}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + return ctx +} + +// TestSubmitSnapshotIfActive_NilRecorderIsNoOp guards the cheap nil guard: the +// receive loop falls back to a nil/NoOp recorder when ROAST retry is inactive, and +// submit must not panic or stash. +func TestSubmitSnapshotIfActive_NilRecorderIsNoOp(t *testing.T) { + ResetSessionHandleRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + submitSnapshotIfActive("session-nil", 1, nil) + + if PendingEvidenceStashedForTest("session-nil", 1) { + t.Fatal("nil recorder must not stash") + } +} + +// TestSubmitSnapshotIfActive_NoOpWhenSessionUnbound asserts that without a +// session-handle binding (the orchestration layer has not run, or the default +// build), submit stashes nothing -- there is no attempt to attribute evidence to. +func TestSubmitSnapshotIfActive_NoOpWhenSessionUnbound(t *testing.T) { + ResetSessionHandleRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + recorder := attempt.NewBoundedRecorder() + recorder.RecordOverflow(7) + submitSnapshotIfActive("session-with-no-binding", 1, recorder) + + if PendingEvidenceStashedForTest("session-with-no-binding", 1) { + t.Fatal("expected no stash when session unbound") + } +} + +// TestSubmitSnapshotIfActive_NoOpWhenRecorderEmpty asserts a bound attempt whose +// recorder captured zero events stashes nothing: the exchange still broadcasts an +// empty proof-of-attendance snapshot, so skipping the stash does not silence-park +// the seat. +func TestSubmitSnapshotIfActive_NoOpWhenRecorderEmpty(t *testing.T) { + ResetSessionHandleRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + const selfMember group.MemberIndex = 1 + ctx := newTestContextForSubmit(t, "session-empty") + SetCurrentAttemptHandleForSession("session-empty", selfMember, roast.AttemptHandle{}, ctx) + + recorder := attempt.NewBoundedRecorder() + submitSnapshotIfActive("session-empty", selfMember, recorder) + + if PendingEvidenceStashedForTest("session-empty", selfMember) { + t.Fatal("expected no stash for an empty snapshot") + } +} + +// TestSubmitSnapshotIfActive_StashesEvidenceWhenBoundAndPopulated is the core +// blame-bridge wiring (RFC-21 Phase 7.3 PR2b-2 step 2): a bound attempt with +// captured evidence stashes the RAW evidence keyed by the attempt's +// (RoastSessionID==ctx.SessionID, member, attemptHash==ctx.Hash()) so the +// transition exchange's BroadcastForcedSnapshot can carry it into the bundle. +func TestSubmitSnapshotIfActive_StashesEvidenceWhenBoundAndPopulated(t *testing.T) { + ResetSessionHandleRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + const selfMember group.MemberIndex = 1 + ctx := newTestContextForSubmit(t, "session-real") + SetCurrentAttemptHandleForSession("session-real", selfMember, roast.AttemptHandle{}, ctx) + + recorder := attempt.NewBoundedRecorder() + recorder.RecordOverflow(3) + recorder.RecordOverflow(3) + recorder.RecordOverflow(5) + submitSnapshotIfActive("session-real", selfMember, recorder) + + evidence, _, ok := takePendingEvidence(ctx.SessionID, selfMember, ctx.Hash()) + if !ok { + t.Fatal("expected stashed evidence after a populated submit") + } + // 2 distinct senders observed (3 twice, 5 once). + if len(evidence.Overflows) != 2 { + t.Fatalf("expected 2 overflow senders stashed; got %d", len(evidence.Overflows)) + } + if evidence.Overflows[3] != 2 || evidence.Overflows[5] != 1 { + t.Fatalf("stashed overflow counts wrong: %+v", evidence.Overflows) + } + // take consumes: a second take finds nothing. + if _, _, ok := takePendingEvidence(ctx.SessionID, selfMember, ctx.Hash()); ok { + t.Fatal("take must consume the stash entry") + } +} + +// TestSubmitSnapshotIfActive_StashesRejectOnlySnapshot guards the all-categories +// emptiness test: a snapshot carrying ONLY reject evidence -- no overflow, no +// conflict -- must still be stashed. A validation-blamable Reject populates +// Evidence.Rejects without any Overflow, and NextAttempt's exclusion path consumes +// snapshot.Rejects; an overflow-only emptiness check would starve the blame +// pipeline. +func TestSubmitSnapshotIfActive_StashesRejectOnlySnapshot(t *testing.T) { + ResetSessionHandleRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + const selfMember group.MemberIndex = 1 + ctx := newTestContextForSubmit(t, "session-reject-only") + SetCurrentAttemptHandleForSession("session-reject-only", selfMember, roast.AttemptHandle{}, ctx) + + recorder := attempt.NewBoundedRecorder() + recorder.RecordReject(2, "attempt_context_hash_mismatch") + submitSnapshotIfActive("session-reject-only", selfMember, recorder) + + evidence, _, ok := takePendingEvidence(ctx.SessionID, selfMember, ctx.Hash()) + if !ok { + t.Fatal("reject-only snapshot must be stashed") + } + if len(evidence.Rejects[2]) == 0 { + t.Fatalf("stashed evidence must carry the reject for sender 2; got %+v", evidence.Rejects) + } +} + +// TestSubmitSnapshotIfActive_MultiSeatStashesPerSeat asserts the PR2b-2 +// member-aware path: with two local seats bound to the SAME attempt (same +// RoastSessionID + attemptHash), each seat stashes its OWN evidence under its own +// member key -- neither overwrites the other (the member-keying that fixes the +// sibling-collision hazard). +func TestSubmitSnapshotIfActive_MultiSeatStashesPerSeat(t *testing.T) { + ResetSessionHandleRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + ctx := newTestContextForSubmit(t, "session-ms") + SetCurrentAttemptHandleForSession("session-ms", 1, roast.AttemptHandle{}, ctx) + SetCurrentAttemptHandleForSession("session-ms", 2, roast.AttemptHandle{}, ctx) + + rec1 := attempt.NewBoundedRecorder() + rec1.RecordOverflow(3) + rec2 := attempt.NewBoundedRecorder() + rec2.RecordOverflow(4) + + submitSnapshotIfActive("session-ms", 1, rec1) + submitSnapshotIfActive("session-ms", 2, rec2) + + ev1, _, ok1 := takePendingEvidence(ctx.SessionID, 1, ctx.Hash()) + ev2, _, ok2 := takePendingEvidence(ctx.SessionID, 2, ctx.Hash()) + if !ok1 || !ok2 { + t.Fatalf("both seats must stash their own evidence; got ok1=%v ok2=%v", ok1, ok2) + } + // Seat 1 observed sender 3 only; seat 2 observed sender 4 only -- no bleed. + if ev1.Overflows[3] != 1 || ev1.Overflows[4] != 0 { + t.Fatalf("seat 1 stash must isolate its own evidence; got %+v", ev1.Overflows) + } + if ev2.Overflows[4] != 1 || ev2.Overflows[3] != 0 { + t.Fatalf("seat 2 stash must isolate its own evidence; got %+v", ev2.Overflows) + } +} + +func TestSetCurrentAttemptHandleForSession_LaterBindingOverwrites(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctxA := newTestContextForSubmit(t, "session-overwrite") + ctxB, _ := attempt.NewAttemptContext( + "session-overwrite", "key-group-submit", []byte{0xAA}, + [attempt.MessageDigestLength]byte{0x42}, 1, + []group.MemberIndex{1, 2, 3, 4, 5}, nil, + ) + h1 := roast.AttemptHandle{} + h2 := roast.AttemptHandle{} + + SetCurrentAttemptHandleForSession("session-overwrite", 1, h1, ctxA) + gotHandle, gotCtx, ok := currentAttemptHandleForCollect("session-overwrite", 1) + if !ok { + t.Fatal("expected binding after first Set") + } + if gotHandle != h1 { + t.Fatal("first binding handle mismatch") + } + if gotCtx.AttemptNumber != ctxA.AttemptNumber { + t.Fatal("first binding context mismatch") + } + + SetCurrentAttemptHandleForSession("session-overwrite", 1, h2, ctxB) + _, gotCtx2, ok := currentAttemptHandleForCollect("session-overwrite", 1) + if !ok { + t.Fatal("expected binding after second Set") + } + if gotCtx2.AttemptNumber != ctxB.AttemptNumber { + t.Fatal("second binding context did not overwrite first") + } +} + +func TestClearCurrentAttemptHandleForSession_RemovesBinding(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newTestContextForSubmit(t, "session-clear") + SetCurrentAttemptHandleForSession("session-clear", 1, roast.AttemptHandle{}, ctx) + if _, _, ok := currentAttemptHandleForCollect("session-clear", 1); !ok { + t.Fatal("setup: binding must exist") + } + ClearCurrentAttemptHandleForSession("session-clear", 1) + if _, _, ok := currentAttemptHandleForCollect("session-clear", 1); ok { + t.Fatal("binding must be cleared") + } +} diff --git a/pkg/frost/signing/roast_runner_bus_frost_native.go b/pkg/frost/signing/roast_runner_bus_frost_native.go new file mode 100644 index 0000000000..1de16eb325 --- /dev/null +++ b/pkg/frost/signing/roast_runner_bus_frost_native.go @@ -0,0 +1,186 @@ +//go:build frost_native + +package signing + +import ( + "crypto/sha256" + "sync" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// RunnerMessageType tags a broadcast interactive-signing message so subscribers +// can route it to the right typed receive stream. +type RunnerMessageType int + +const ( + // RunnerMsgCommitments carries a member's round-1 commitments. + RunnerMsgCommitments RunnerMessageType = iota + // RunnerMsgSigningPackage carries the coordinator's signed SigningPackage. + RunnerMsgSigningPackage + // RunnerMsgShareSubmission carries a member's signed ShareSubmission. + RunnerMsgShareSubmission + // RunnerMsgEvidenceSnapshot carries a member's signed LocalEvidenceSnapshot. + RunnerMsgEvidenceSnapshot + // RunnerMsgTransitionBundle carries the coordinator's TransitionMessage. + RunnerMsgTransitionBundle +) + +// RunnerMessage is one broadcast message on the interactive-signing bus. Payload +// is the serialized content (commitments bytes, SigningPackage/ShareSubmission/ +// LocalEvidenceSnapshot/TransitionMessage envelope bytes); the bus treats it as +// opaque and never interprets it. +type RunnerMessage struct { + Type RunnerMessageType + Sender group.MemberIndex + Attempt [attempt.MessageDigestLength]byte + Payload []byte +} + +// contentHash is the full-message identity used for retransmission dedup. It +// covers EVERY field including the payload, so two messages from the same sender +// with different bodies hash differently and are both delivered - critical +// because for signing packages and shares a body-different duplicate IS +// equivocation evidence the collector must see. Only byte-identical +// retransmissions collide and are suppressed. +func (m RunnerMessage) contentHash() [sha256.Size]byte { + h := sha256.New() + h.Write([]byte{byte(m.Type), byte(m.Sender)}) + h.Write(m.Attempt[:]) + h.Write(m.Payload) + var out [sha256.Size]byte + copy(out[:], h.Sum(nil)) + return out +} + +// RunnerBus is the interactive-signing broadcast mesh. Production wraps pkg/net; +// the in-process implementation here drives the runner's deterministic unit +// tests without real networking. +type RunnerBus interface { + // Broadcast delivers msg to every subscriber. Byte-identical retransmissions + // are deduplicated per subscriber; body-different messages from the same + // sender are NEVER suppressed (they are equivocation evidence). The caller + // records its OWN produced messages into its collector/coordinator directly + // and must not rely on receiving its own broadcast back. + Broadcast(msg RunnerMessage) + // Subscribe registers a receiver and returns its typed streams. The harness + // wires every node up front (a subscriber does not receive messages + // broadcast before it subscribed). + Subscribe() *RunnerBusSubscriber +} + +// RunnerBusSubscriber exposes one node's typed receive streams plus a +// per-subscriber dedup set keyed by full message content. +type RunnerBusSubscriber struct { + commitments chan RunnerMessage + signingPackages chan RunnerMessage + shares chan RunnerMessage + evidenceSnapshots chan RunnerMessage + transitionBundles chan RunnerMessage + + mu sync.Mutex + seen map[[sha256.Size]byte]struct{} +} + +// Commitments returns the round-1 commitments stream. +func (s *RunnerBusSubscriber) Commitments() <-chan RunnerMessage { return s.commitments } + +// SigningPackages returns the coordinator signing-package stream. +func (s *RunnerBusSubscriber) SigningPackages() <-chan RunnerMessage { return s.signingPackages } + +// Shares returns the share-submission stream. +func (s *RunnerBusSubscriber) Shares() <-chan RunnerMessage { return s.shares } + +// EvidenceSnapshots returns the evidence-snapshot stream. +func (s *RunnerBusSubscriber) EvidenceSnapshots() <-chan RunnerMessage { return s.evidenceSnapshots } + +// TransitionBundles returns the transition-bundle stream. +func (s *RunnerBusSubscriber) TransitionBundles() <-chan RunnerMessage { return s.transitionBundles } + +func (s *RunnerBusSubscriber) streamFor(t RunnerMessageType) chan RunnerMessage { + switch t { + case RunnerMsgCommitments: + return s.commitments + case RunnerMsgSigningPackage: + return s.signingPackages + case RunnerMsgShareSubmission: + return s.shares + case RunnerMsgEvidenceSnapshot: + return s.evidenceSnapshots + case RunnerMsgTransitionBundle: + return s.transitionBundles + default: + return nil + } +} + +func (s *RunnerBusSubscriber) deliver(hash [sha256.Size]byte, msg RunnerMessage) { + s.mu.Lock() + if _, dup := s.seen[hash]; dup { + s.mu.Unlock() + return + } + s.seen[hash] = struct{}{} + s.mu.Unlock() + + if stream := s.streamFor(msg.Type); stream != nil { + // Own the payload bytes per delivery: RunnerMessage.Payload is a slice, + // so without this every queued message would alias one backing array. + // The broadcaster mutating/reusing it after Broadcast returns, or one + // receiver mutating what it read, would then change another subscriber's + // view - and the body the bus hashed for dedup could differ from the body + // delivered, silently destroying the equivocation evidence this bus + // exists to preserve. The dedup hash was computed from these same bytes, + // so the copy is byte-identical and consistent with it. + delivered := msg + delivered.Payload = append([]byte(nil), msg.Payload...) + stream <- delivered + } +} + +// inProcessRunnerBus is the deterministic in-process RunnerBus for runner unit +// tests. Streams are buffered (bufferSize per type per subscriber); a Broadcast +// blocks only if a subscriber's buffer for that type is full, so the harness +// sizes the buffer to the expected message volume. +type inProcessRunnerBus struct { + mu sync.Mutex + subscribers []*RunnerBusSubscriber + bufferSize int +} + +// NewInProcessRunnerBus returns an in-process bus with per-stream buffers of the +// given size. +func NewInProcessRunnerBus(bufferSize int) RunnerBus { + if bufferSize < 1 { + bufferSize = 1 + } + return &inProcessRunnerBus{bufferSize: bufferSize} +} + +func (b *inProcessRunnerBus) Subscribe() *RunnerBusSubscriber { + s := &RunnerBusSubscriber{ + commitments: make(chan RunnerMessage, b.bufferSize), + signingPackages: make(chan RunnerMessage, b.bufferSize), + shares: make(chan RunnerMessage, b.bufferSize), + evidenceSnapshots: make(chan RunnerMessage, b.bufferSize), + transitionBundles: make(chan RunnerMessage, b.bufferSize), + seen: map[[sha256.Size]byte]struct{}{}, + } + b.mu.Lock() + b.subscribers = append(b.subscribers, s) + b.mu.Unlock() + return s +} + +func (b *inProcessRunnerBus) Broadcast(msg RunnerMessage) { + hash := msg.contentHash() + b.mu.Lock() + subscribers := append([]*RunnerBusSubscriber(nil), b.subscribers...) + b.mu.Unlock() + // Deliver outside the bus lock so a slow/full subscriber stream cannot block + // other subscribers' registration; each subscriber guards its own dedup set. + for _, s := range subscribers { + s.deliver(hash, msg) + } +} diff --git a/pkg/frost/signing/roast_runner_bus_frost_native_test.go b/pkg/frost/signing/roast_runner_bus_frost_native_test.go new file mode 100644 index 0000000000..5f9f9ba117 --- /dev/null +++ b/pkg/frost/signing/roast_runner_bus_frost_native_test.go @@ -0,0 +1,128 @@ +//go:build frost_native + +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func testRunnerMessage(t RunnerMessageType, sender group.MemberIndex, payload []byte) RunnerMessage { + return RunnerMessage{ + Type: t, + Sender: sender, + Attempt: [attempt.MessageDigestLength]byte{0x42}, + Payload: payload, + } +} + +// recvOrFail reads one message from a stream without blocking the test forever. +func recvOrFail(t *testing.T, stream <-chan RunnerMessage, what string) RunnerMessage { + t.Helper() + select { + case msg := <-stream: + return msg + default: + t.Fatalf("expected a %s message, stream was empty", what) + return RunnerMessage{} + } +} + +func TestInProcessRunnerBus_BroadcastsToAllSubscribersOnTypedStream(t *testing.T) { + bus := NewInProcessRunnerBus(8) + a := bus.Subscribe() + b := bus.Subscribe() + + bus.Broadcast(testRunnerMessage(RunnerMsgSigningPackage, 1, []byte{0xde, 0xad})) + + for name, sub := range map[string]*RunnerBusSubscriber{"a": a, "b": b} { + msg := recvOrFail(t, sub.SigningPackages(), "signing package") + if msg.Sender != 1 || string(msg.Payload) != string([]byte{0xde, 0xad}) { + t.Fatalf("subscriber %s got wrong message: %+v", name, msg) + } + // It must NOT also appear on another typed stream. + select { + case other := <-sub.Commitments(): + t.Fatalf("subscriber %s leaked a package onto the commitments stream: %+v", name, other) + default: + } + } +} + +func TestInProcessRunnerBus_DedupsExactRetransmission(t *testing.T) { + bus := NewInProcessRunnerBus(8) + sub := bus.Subscribe() + + msg := testRunnerMessage(RunnerMsgShareSubmission, 2, []byte{0x01, 0x02}) + bus.Broadcast(msg) + bus.Broadcast(msg) // byte-identical retransmission + + recvOrFail(t, sub.Shares(), "share") + select { + case dup := <-sub.Shares(): + t.Fatalf("exact retransmission must be deduped, got a second delivery: %+v", dup) + default: + } +} + +// The critical property: a body-DIFFERENT message from the same sender must NOT +// be suppressed - for packages and shares it is equivocation evidence the +// collector has to see. Deduping by (attempt, sender) would silently drop it. +func TestInProcessRunnerBus_DeliversBodyDifferentDuplicateFromSameSender(t *testing.T) { + bus := NewInProcessRunnerBus(8) + sub := bus.Subscribe() + + bus.Broadcast(testRunnerMessage(RunnerMsgSigningPackage, 1, []byte{0xaa})) + bus.Broadcast(testRunnerMessage(RunnerMsgSigningPackage, 1, []byte{0xbb})) // same sender, different body + + first := recvOrFail(t, sub.SigningPackages(), "first package") + second := recvOrFail(t, sub.SigningPackages(), "second (equivocating) package") + if string(first.Payload) == string(second.Payload) { + t.Fatal("expected two distinct package bodies to be delivered") + } + got := map[string]bool{string(first.Payload): true, string(second.Payload): true} + if !got[string([]byte{0xaa})] || !got[string([]byte{0xbb})] { + t.Fatalf("expected both 0xaa and 0xbb bodies delivered, got: %v", got) + } +} + +func TestInProcessRunnerBus_LateSubscriberMissesPastMessages(t *testing.T) { + bus := NewInProcessRunnerBus(8) + bus.Broadcast(testRunnerMessage(RunnerMsgCommitments, 1, []byte{0x01})) + + late := bus.Subscribe() + select { + case msg := <-late.Commitments(): + t.Fatalf("a late subscriber must not receive past messages, got: %+v", msg) + default: + } +} + +// Each delivered message must own its payload bytes: mutating the broadcaster's +// slice after Broadcast, or one subscriber's received slice, must not change +// another subscriber's view - otherwise the body the bus hashed for dedup could +// differ from the body delivered, destroying equivocation evidence. +func TestInProcessRunnerBus_OwnsDeliveredPayload(t *testing.T) { + bus := NewInProcessRunnerBus(8) + a := bus.Subscribe() + b := bus.Subscribe() + + payload := []byte{0xaa, 0xbb} + bus.Broadcast(testRunnerMessage(RunnerMsgSigningPackage, 1, payload)) + + // Mutate the broadcaster's original slice after Broadcast returns. + payload[0] = 0xff + msgA := recvOrFail(t, a.SigningPackages(), "package (a)") + if msgA.Payload[0] != 0xaa { + t.Fatalf("subscriber a saw the broadcaster's post-broadcast mutation: %x", msgA.Payload) + } + + // Mutating a's received payload must not corrupt b's. + msgA.Payload[0] = 0xee + msgB := recvOrFail(t, b.SigningPackages(), "package (b)") + if msgB.Payload[0] != 0xaa { + t.Fatalf("subscriber b's payload was corrupted by a's mutation: %x", msgB.Payload) + } +} diff --git a/pkg/frost/signing/roast_runner_bus_net_e2e_frost_native_test.go b/pkg/frost/signing/roast_runner_bus_net_e2e_frost_native_test.go new file mode 100644 index 0000000000..d726079c2b --- /dev/null +++ b/pkg/frost/signing/roast_runner_bus_net_e2e_frost_native_test.go @@ -0,0 +1,188 @@ +//go:build frost_native + +package signing + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + netlocal "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// netSigningHarness wires n interactive signing runners over n REAL pkg/net +// BroadcastChannel runner buses (one operator per seat, all on one in-memory +// network) so a full interactive signing round flows through the production +// transport adapter (broadcastChannelRunnerBus / NewBroadcastChannelRunnerBus) +// end-to-end - exercising wire serialization, the claimed-seat<->operator-key +// authentication, the per-type demux, and bounded delivery - rather than the +// in-process test bus the rest of the runner suite uses. +type netSigningHarness struct { + runners []*interactiveSigningRunner + coords []roast.Coordinator + handles []roast.AttemptHandle +} + +// buildInteractiveSigningNetHarness builds an n-seat interactive signing round +// over the real pkg/net transport. Seat i (1-based) is held by operator i, and +// the MembershipValidator maps each seat to that operator's address so the +// adapter authenticates every broadcast's claimed seat against the authenticated +// sender key. The engine is the deterministic fake (no cgo / no real FROST); this +// validates the TRANSPORT, not the crypto, which the engine suite covers. +func buildInteractiveSigningNetHarness( + t *testing.T, + ctx context.Context, + n int, + threshold uint16, +) netSigningHarness { + t.Helper() + + included := make([]group.MemberIndex, 0, n) + for i := 1; i <= n; i++ { + included = append(included, group.MemberIndex(i)) + } + + // One operator per seat. The same chain signing maps an operator public key to + // the address the MembershipValidator checks, so the validator and the + // per-operator net providers agree on who holds each seat. + chainSigning := local_v1.Connect(n, n).Signing() + publicKeys := make([]*operator.PublicKey, n) + addresses := make([]chain.Address, n) + for i := 0; i < n; i++ { + _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (seat %d): %v", i+1, err) + } + publicKeys[i] = publicKey + addresses[i] = chainSigning.PublicKeyBytesToAddress( + operator.MarshalUncompressed(publicKey), + ) + } + validator := group.NewMembershipValidator(&testutils.MockLogger{}, addresses, chainSigning) + + dkgKey := []byte{0x01, 0x02} + attemptCtx, err := attempt.NewAttemptContext( + "session-net-1", "key-group-1", dkgKey, + [attempt.MessageDigestLength]byte{0x42}, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt context: %v", err) + } + + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + logger := &testutils.MockLogger{} + + h := netSigningHarness{} + for i := 0; i < n; i++ { + member := group.MemberIndex(i + 1) + + // Each seat broadcasts on its OWN provider+channel; same-named channels on + // the in-memory network are interconnected, so a broadcast reaches every + // seat. The provider stamps this operator's key as the authenticated + // sender, which is what the adapter binds the claimed seat to. + channel, err := netlocal.ConnectWithKey(publicKeys[i]). + BroadcastChannelFor("frost-roast-interactive-signing") + if err != nil { + t.Fatalf("broadcast channel (seat %d): %v", member, err) + } + bus, err := NewBroadcastChannelRunnerBus(ctx, logger, channel, validator) + if err != nil { + t.Fatalf("runner bus (seat %d): %v", member, err) + } + + coord := roast.NewInMemoryCoordinatorWithSigning(member, signer, verifier) + handle, err := coord.BeginAttempt(attemptCtx) + if err != nil { + t.Fatalf("begin attempt (seat %d): %v", member, err) + } + ara, err := NewActiveRoastAttempt(coord, handle, attemptCtx, "session-net-1", nil, dkgKey) + if err != nil { + t.Fatalf("active attempt (seat %d): %v", member, err) + } + engine := newFakeInteractiveSigningEngine() + // The fake engine's derivation must agree with the binding's RFC-21 + // election (a real engine derives the same coordinator). + engine.coordinatorIdentifier = uint16(ara.ElectedCoordinator()) + collector := roast.NewRound2Collector(verifier) + runner, err := newInteractiveSigningRunner( + ara, member, threshold, engine, collector, coord, signer, bus, + ) + if err != nil { + t.Fatalf("runner (seat %d): %v", member, err) + } + + h.coords = append(h.coords, coord) + h.handles = append(h.handles, handle) + h.runners = append(h.runners, runner) + } + return h +} + +// runAllAndAssertSucceed runs every seat's runner concurrently and asserts each +// returns the signature and transitions its attempt to Succeeded. +func (h netSigningHarness) runAllAndAssertSucceed(t *testing.T, ctx context.Context) { + t.Helper() + sigs := make([][]byte, len(h.runners)) + errs := make([]error, len(h.runners)) + var wg sync.WaitGroup + for i := range h.runners { + wg.Add(1) + go func(idx int) { + defer wg.Done() + sigs[idx], errs[idx] = h.runners[idx].Run(ctx) + }(i) + } + wg.Wait() + + for i := range h.runners { + member := i + 1 + if errs[i] != nil { + t.Fatalf("seat %d run failed over the net transport: %v", member, errs[i]) + } + if string(sigs[i]) != "fake-bip340-signature" { + t.Fatalf("seat %d unexpected signature: %q", member, sigs[i]) + } + state, err := h.coords[i].State(h.handles[i]) + if err != nil { + t.Fatalf("seat %d state: %v", member, err) + } + if state != roast.AttemptStateSucceeded { + t.Fatalf("seat %d: expected Succeeded, got %v", member, state) + } + } +} + +// TestInteractiveSigningRunner_NetTransport_FullIncludedRound runs a complete +// interactive signing round over the REAL pkg/net transport with a full-included +// attempt (group size == threshold == 2, every seat signs). It proves the round +// completes when every RunnerMessage type (commitments, signing package, shares) +// is serialized, authenticated by seat, demuxed, and delivered by the production +// adapter rather than the in-process bus. +func TestInteractiveSigningRunner_NetTransport_FullIncludedRound(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + buildInteractiveSigningNetHarness(t, ctx, 2, 2).runAllAndAssertSucceed(t, ctx) +} + +// TestInteractiveSigningRunner_NetTransport_ThresholdSubsetRound runs the round +// over the real transport with an oversized included set (group size 3, threshold +// 2): the coordinator finalizes over a t-subset and the remaining committed seat +// is an observer (RFC-21 Phase 7.3 t-of-included). Every seat still obtains the +// signature and reaches Succeeded, proving the subset/observer flow works across +// the production transport, not only the in-process bus. +func TestInteractiveSigningRunner_NetTransport_ThresholdSubsetRound(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + buildInteractiveSigningNetHarness(t, ctx, 3, 2).runAllAndAssertSucceed(t, ctx) +} diff --git a/pkg/frost/signing/roast_runner_bus_net_frost_native.go b/pkg/frost/signing/roast_runner_bus_net_frost_native.go new file mode 100644 index 0000000000..651372b5d2 --- /dev/null +++ b/pkg/frost/signing/roast_runner_bus_net_frost_native.go @@ -0,0 +1,325 @@ +//go:build frost_native + +package signing + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "sync" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This file implements the production RunnerBus over keep-core's pkg/net +// BroadcastChannel, per the 2026-06-17 Codex+Gemini transport consult. It is a +// THIN adapter: it does NOT create or own the channel (the wallet signing +// channel is created and membership-filtered by pkg/tbtc) - it is constructed +// from the channel + the group MembershipValidator the existing signing.Request +// already carries. +// +// It honors the runner's two transport-contract assumptions: +// +// 1. RunnerMessage.Sender is the AUTHENTICATED seat. The wire body carries a +// CLAIMED sender_id; the adapter accepts it only after IsValidMembership +// confirms the claimed seat belongs to the message's authenticated operator +// public key, then sets Sender from it. It NEVER trusts a sender field +// inside Payload. (An operator can hold multiple seats - MembershipValidator +// maps an address to a SET of positions - so a public key does not resolve +// to a single member index; the claimed-and-validated seat is the only sound +// binding.) +// 2. Delivery never blocks an honest broadcaster. The single Recv handler +// demuxes into bounded per-subscriber streams with non-blocking sends +// (dropping the newest on overflow). A dropped message is PERMANENT: pkg/net +// filters retransmissions before this handler (BroadcastChannel.Recv wraps it +// with retransmission support), so a retransmit never re-reaches the bus. The +// streams are therefore sized to hold a whole attempt's honest message volume +// so honest operation never overflows; overflow only arises when a peer floods +// distinct messages faster than the runner drains, which degrades the attempt +// to a ROAST retry (pkg/net per-peer limits backstop the flood). Blocking +// instead is unsafe: the runner drains the streams in phases, so blocking on a +// stream it has finished with would stall delivery of the ones it still needs. + +// runnerTransportType maps each RunnerMessageType to the distinct pkg/net +// message Type() string the BroadcastChannel dispatches on. +var runnerTransportType = map[RunnerMessageType]string{ + RunnerMsgCommitments: "frost/roast_runner/commitments", + RunnerMsgSigningPackage: "frost/roast_runner/signing_package", + RunnerMsgShareSubmission: "frost/roast_runner/share_submission", + RunnerMsgEvidenceSnapshot: "frost/roast_runner/evidence_snapshot", + RunnerMsgTransitionBundle: "frost/roast_runner/transition_bundle", +} + +// runnerTransportMessage is the wire envelope for one RunnerMessage. The five +// runner stream types share this body and are distinguished by the Type() +// string (set per registered unmarshaler), matching the RegisterUnmarshallers +// convention. The body carries the CLAIMED sender seat, the attempt context +// hash, and the opaque runner payload. +type runnerTransportMessage struct { + messageType RunnerMessageType + sender group.MemberIndex + attempt [attemptContextHashLength]byte + payload []byte +} + +// attemptContextHashLength is the fixed wire length of the attempt context hash +// (a SHA-256 digest). It equals attempt.MessageDigestLength; redeclared as a +// constant here to keep the fixed-size prefix framing self-documenting. +const attemptContextHashLength = sha256.Size + +// Type returns the pkg/net dispatch tag for this message's runner type. +func (m *runnerTransportMessage) Type() string { + return runnerTransportType[m.messageType] +} + +// Marshal encodes the body as: sender_id (uint32 big-endian) || attempt_context +// _hash (32 bytes) || payload (remaining bytes). The fixed-size prefix makes the +// boundary unambiguous, so the variable-length payload needs no length prefix. +func (m *runnerTransportMessage) Marshal() ([]byte, error) { + if m.sender == 0 { + return nil, fmt.Errorf("runner transport: sender is zero") + } + out := make([]byte, 4+attemptContextHashLength+len(m.payload)) + binary.BigEndian.PutUint32(out[0:4], uint32(m.sender)) + copy(out[4:4+attemptContextHashLength], m.attempt[:]) + copy(out[4+attemptContextHashLength:], m.payload) + return out, nil +} + +// Unmarshal decodes a body produced by Marshal. messageType is preset by the +// registered unmarshaler (one per Type() string), so it is not carried on the +// wire. +func (m *runnerTransportMessage) Unmarshal(data []byte) error { + const prefix = 4 + attemptContextHashLength + if len(data) < prefix { + return fmt.Errorf( + "runner transport: message length [%d] shorter than the %d-byte header", + len(data), prefix, + ) + } + // Validate the raw 4-byte seat BEFORE narrowing to group.MemberIndex (uint8). + // A truncating cast would wrap an out-of-range claim (e.g. 259 -> 3) and let + // it pass IsValidMembership for whoever holds the wrapped seat - and round-1 + // commitments carry no inner signature to reject it later. Reject any + // non-canonical seat at the decode boundary. + rawSender := binary.BigEndian.Uint32(data[0:4]) + if rawSender == 0 || rawSender > uint32(group.MaxMemberIndex) { + return fmt.Errorf( + "runner transport: sender id [%d] out of range [1, %d]", + rawSender, group.MaxMemberIndex, + ) + } + m.sender = group.MemberIndex(rawSender) + copy(m.attempt[:], data[4:prefix]) + m.payload = append([]byte(nil), data[prefix:]...) + return nil +} + +// registerRunnerTransportUnmarshalers registers one unmarshaler per runner +// stream type, each presetting messageType so Type() and the demux know the +// stream without a wire type tag. +func registerRunnerTransportUnmarshalers(channel net.BroadcastChannel) { + for messageType := range runnerTransportType { + mt := messageType + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &runnerTransportMessage{messageType: mt} + }) + } +} + +const ( + // defaultRunnerBusStreamBuffer bounds each per-subscriber stream. Because a + // drop here is permanent (pkg/net filters retransmissions before the handler), + // it is sized well above a single attempt's honest message volume - at most + // one message per included member per type, plus a small equivocation + // allowance the collector caps anyway - for the expected group sizes (tBTC + // wallets are ~100 seats). Honest operation thus never overflows; only a peer + // flooding distinct messages faster than the runner drains can, and that + // degrades the attempt to a retry rather than silently losing an honest one. + defaultRunnerBusStreamBuffer = 1024 + // defaultRunnerBusSeenBound caps the per-subscriber dedup set so a peer + // flooding body-different messages cannot grow it without bound. On overflow + // the set resets (coarse but bounded); a re-delivered byte-identical message + // is harmless because pkg/net already dedups retransmissions and the + // collector is idempotent. + defaultRunnerBusSeenBound = 4096 +) + +// broadcastChannelRunnerBus is the production RunnerBus over a net.BroadcastChannel. +type broadcastChannelRunnerBus struct { + ctx context.Context + logger log.StandardLogger + channel net.BroadcastChannel + membershipValidator *group.MembershipValidator + streamBuffer int + seenBound int + + mu sync.Mutex + subscribers []*RunnerBusSubscriber +} + +// NewBroadcastChannelRunnerBus returns a RunnerBus over the given wallet signing +// broadcast channel. It registers the runner message unmarshalers and installs +// a receive handler for the lifetime of ctx; cancel ctx (e.g. at session end) +// to stop receiving. The channel and membershipValidator are the ones the +// existing signing.Request already carries; this adapter does not create them. +func NewBroadcastChannelRunnerBus( + ctx context.Context, + logger log.StandardLogger, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, +) (RunnerBus, error) { + if ctx == nil { + return nil, fmt.Errorf("runner bus: context is nil") + } + if channel == nil { + return nil, fmt.Errorf("runner bus: broadcast channel is nil") + } + if membershipValidator == nil { + return nil, fmt.Errorf("runner bus: membership validator is nil") + } + if logger == nil { + logger = log.Logger("frost-roast-runner-bus") + } + + b := &broadcastChannelRunnerBus{ + ctx: ctx, + logger: logger, + channel: channel, + membershipValidator: membershipValidator, + streamBuffer: defaultRunnerBusStreamBuffer, + seenBound: defaultRunnerBusSeenBound, + } + + registerRunnerTransportUnmarshalers(channel) + channel.Recv(ctx, b.handleMessage) + + return b, nil +} + +// Subscribe registers a receiver and returns its typed streams. Subscribing +// before any Broadcast is the caller's responsibility (the runner subscribes in +// its constructor). +func (b *broadcastChannelRunnerBus) Subscribe() *RunnerBusSubscriber { + s := &RunnerBusSubscriber{ + commitments: make(chan RunnerMessage, b.streamBuffer), + signingPackages: make(chan RunnerMessage, b.streamBuffer), + shares: make(chan RunnerMessage, b.streamBuffer), + evidenceSnapshots: make(chan RunnerMessage, b.streamBuffer), + transitionBundles: make(chan RunnerMessage, b.streamBuffer), + seen: make(map[[sha256.Size]byte]struct{}), + } + + b.mu.Lock() + b.subscribers = append(b.subscribers, s) + b.mu.Unlock() + + return s +} + +// Broadcast publishes msg to the channel. It is fire-and-forget: pkg/net handles +// retransmission, so a Send error is logged (not surfaced) and the runner +// records its OWN produced messages directly rather than relying on self-echo. +func (b *broadcastChannelRunnerBus) Broadcast(msg RunnerMessage) { + wire := &runnerTransportMessage{ + messageType: msg.Type, + sender: msg.Sender, + attempt: msg.Attempt, + payload: msg.Payload, + } + if err := b.channel.Send(b.ctx, wire); err != nil { + b.logger.Warnf("runner bus: failed to broadcast [%s] message: [%v]", wire.Type(), err) + } +} + +// handleMessage is the single Recv handler. It authenticates the claimed sender +// seat against the message's authenticated operator public key, then demuxes the +// message into every subscriber's typed stream (non-blocking, deduped). +func (b *broadcastChannelRunnerBus) handleMessage(m net.Message) { + wire, ok := m.Payload().(*runnerTransportMessage) + if !ok { + // A message of an unregistered/foreign type, or a decode failure. + return + } + + // Bind the CLAIMED seat to the AUTHENTICATED operator public key. An operator + // may hold several seats, so this validates membership of the specific + // claimed seat rather than resolving the key to one index. A spoofed seat (a + // seat the sender's key was not selected to) is dropped here, before the + // runner ever sees it. + if !b.membershipValidator.IsValidMembership(wire.sender, m.SenderPublicKey()) { + b.logger.Warnf( + "runner bus: dropping [%s] message claiming unauthenticated seat [%d]", + wire.Type(), wire.sender, + ) + return + } + + msg := RunnerMessage{ + Type: wire.messageType, + Sender: wire.sender, + Attempt: wire.attempt, + Payload: wire.payload, + } + hash := msg.contentHash() + + b.mu.Lock() + subscribers := append([]*RunnerBusSubscriber(nil), b.subscribers...) + b.mu.Unlock() + + for _, s := range subscribers { + s.deliverNonBlocking(hash, msg, b.seenBound) + } +} + +// deliverNonBlocking routes msg into the matching typed stream WITHOUT blocking: +// on a full stream it drops the newest message (earlier, useful ones stay +// queued). It dedups by full content hash per subscriber - byte-identical +// retransmissions are suppressed, while body-different messages (equivocation +// evidence) are delivered while the buffer permits. seenBound caps the dedup +// set; on overflow it resets (bounded - a rare re-delivery is harmless given +// pkg/net's own retransmission dedup and the collector's idempotent recording). +func (s *RunnerBusSubscriber) deliverNonBlocking( + hash [sha256.Size]byte, + msg RunnerMessage, + seenBound int, +) { + stream := s.streamFor(msg.Type) + if stream == nil { + return + } + // Own the payload bytes per delivery (matches the in-process bus): the + // receive path may reuse the backing array, and a receiver must not be able + // to mutate another subscriber's view. + delivered := msg + delivered.Payload = append([]byte(nil), msg.Payload...) + + // Dedup-check, enqueue, and record-seen under one lock. The non-blocking + // send never blocks (select/default), so holding the lock across it is safe. + s.mu.Lock() + defer s.mu.Unlock() + if _, dup := s.seen[hash]; dup { + return + } + select { + case stream <- delivered: + // Record as seen ONLY after a successful enqueue, so a drop never poisons + // the dedup set against a later re-delivery of the same content. (Standard + // pkg/net retransmissions are filtered upstream and do NOT re-reach this + // handler, so this guards only a non-retransmit re-delivery; it is not a + // recovery path for an overflow drop - the buffer sizing is what keeps + // honest messages from being dropped in the first place.) + if seenBound > 0 && len(s.seen) >= seenBound { + s.seen = make(map[[sha256.Size]byte]struct{}) + } + s.seen[hash] = struct{}{} + default: + // Stream full: drop the newest. This is permanent (retransmissions are + // filtered upstream), so the buffer is sized above honest volume and this + // only occurs under a flood (-> ROAST retry). Left un-seen so a + // non-retransmit re-delivery, if any, is still accepted. + } +} diff --git a/pkg/frost/signing/roast_runner_bus_net_frost_native_test.go b/pkg/frost/signing/roast_runner_bus_net_frost_native_test.go new file mode 100644 index 0000000000..f8cfd45efe --- /dev/null +++ b/pkg/frost/signing/roast_runner_bus_net_frost_native_test.go @@ -0,0 +1,353 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "encoding/binary" + "testing" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// fakeNetMessage is a minimal net.Message for exercising the adapter's receive +// path (sender authentication + demux) without standing up a full network. The +// authenticated author key is SenderPublicKey(); Payload() is the unmarshaled +// runner transport message the channel would hand the handler. +type fakeNetMessage struct { + senderPublicKey []byte + payload interface{} +} + +func (m fakeNetMessage) TransportSenderID() net.TransportIdentifier { return nil } +func (m fakeNetMessage) SenderPublicKey() []byte { return m.senderPublicKey } +func (m fakeNetMessage) Payload() interface{} { return m.payload } +func (m fakeNetMessage) Seqno() uint64 { return 0 } +func (m fakeNetMessage) Type() string { + if w, ok := m.payload.(*runnerTransportMessage); ok { + return w.Type() + } + return "" +} + +// runnerBusAuthFixture builds a three-seat group with a MULTI-SEAT operator +// (operator A holds seats 1 and 3; operator B holds seat 2) and a bus wired to +// the resulting MembershipValidator. It returns the bus plus each operator's +// authenticated public-key bytes and an outsider's key (not in the group). +type runnerBusAuthFixture struct { + bus *broadcastChannelRunnerBus + operatorA []byte // seats 1 and 3 + operatorB []byte // seat 2 + outsider []byte // not selected + streamSize int +} + +func newRunnerBusAuthFixture(t *testing.T, streamSize int) runnerBusAuthFixture { + t.Helper() + signing := local_v1.Connect(3, 3).Signing() + + key := func() []byte { + _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("generate operator key: %v", err) + } + return operator.MarshalUncompressed(publicKey) + } + operatorA, operatorB, outsider := key(), key(), key() + + addrA := signing.PublicKeyBytesToAddress(operatorA) + addrB := signing.PublicKeyBytesToAddress(operatorB) + // Ordered seats: 1 -> A, 2 -> B, 3 -> A (operator A is multi-seat). + validator := group.NewMembershipValidator( + &testutils.MockLogger{}, + []chain.Address{addrA, addrB, addrA}, + signing, + ) + + bus := &broadcastChannelRunnerBus{ + logger: &testutils.MockLogger{}, + membershipValidator: validator, + streamBuffer: streamSize, + seenBound: defaultRunnerBusSeenBound, + } + return runnerBusAuthFixture{ + bus: bus, + operatorA: operatorA, + operatorB: operatorB, + outsider: outsider, + streamSize: streamSize, + } +} + +func shareMessage(sender group.MemberIndex, authorPublicKey []byte, payload string) fakeNetMessage { + return fakeNetMessage{ + senderPublicKey: authorPublicKey, + payload: &runnerTransportMessage{ + messageType: RunnerMsgShareSubmission, + sender: sender, + attempt: [attemptContextHashLength]byte{0x42}, + payload: []byte(payload), + }, + } +} + +func TestBroadcastChannelRunnerBus_AuthenticatedMessageDemuxed(t *testing.T) { + f := newRunnerBusAuthFixture(t, 8) + sub := f.bus.Subscribe() + + // Operator A authentically sends as seat 1 (a seat it holds). + f.bus.handleMessage(shareMessage(1, f.operatorA, "share-from-1")) + + select { + case msg := <-sub.Shares(): + if msg.Sender != 1 { + t.Fatalf("unexpected sender: [%d]", msg.Sender) + } + if string(msg.Payload) != "share-from-1" { + t.Fatalf("unexpected payload: [%q]", msg.Payload) + } + if msg.Type != RunnerMsgShareSubmission { + t.Fatalf("unexpected type: [%v]", msg.Type) + } + default: + t.Fatal("expected an authenticated message to be delivered") + } +} + +func TestBroadcastChannelRunnerBus_RejectsSpoofedSeat(t *testing.T) { + f := newRunnerBusAuthFixture(t, 8) + sub := f.bus.Subscribe() + + // Operator B (seat 2) claims seat 1 - a seat its key was NOT selected to. + f.bus.handleMessage(shareMessage(1, f.operatorB, "spoofed")) + // An outsider (no seat) claims seat 1. + f.bus.handleMessage(shareMessage(1, f.outsider, "outsider")) + + select { + case msg := <-sub.Shares(): + t.Fatalf("expected spoofed-seat messages to be dropped, got sender [%d]", msg.Sender) + default: + } +} + +func TestBroadcastChannelRunnerBus_MultiSeatOperator(t *testing.T) { + f := newRunnerBusAuthFixture(t, 8) + sub := f.bus.Subscribe() + + // Operator A holds seats 1 AND 3: it may authentically send as either, but + // NOT as seat 2 (operator B's). + f.bus.handleMessage(shareMessage(3, f.operatorA, "share-from-3")) + f.bus.handleMessage(shareMessage(2, f.operatorA, "claiming-Bs-seat")) + + got := map[group.MemberIndex]string{} + for { + select { + case msg := <-sub.Shares(): + got[msg.Sender] = string(msg.Payload) + continue + default: + } + break + } + if len(got) != 1 || got[3] != "share-from-3" { + t.Fatalf("expected only seat 3 delivered, got %v", got) + } +} + +func TestBroadcastChannelRunnerBus_DedupsByteIdentical(t *testing.T) { + f := newRunnerBusAuthFixture(t, 8) + sub := f.bus.Subscribe() + + msg := shareMessage(1, f.operatorA, "same-body") + f.bus.handleMessage(msg) + f.bus.handleMessage(msg) // a retransmission of the identical content + + count := 0 + for { + select { + case <-sub.Shares(): + count++ + continue + default: + } + break + } + if count != 1 { + t.Fatalf("expected one delivery for byte-identical messages, got [%d]", count) + } +} + +func TestRunnerTransportMessage_RejectsOutOfRangeSender(t *testing.T) { + frame := make([]byte, 4+attemptContextHashLength+2) + // A seat beyond the valid range must be rejected at decode, BEFORE the uint8 + // narrowing - else it would wrap (e.g. 256+3 -> 3) and pass authentication. + binary.BigEndian.PutUint32(frame[0:4], uint32(group.MaxMemberIndex)+1) + if err := (&runnerTransportMessage{}).Unmarshal(frame); err == nil { + t.Fatal("expected an out-of-range sender id to be rejected") + } + binary.BigEndian.PutUint32(frame[0:4], 256+3) // wraps to seat 3 if truncated + if err := (&runnerTransportMessage{}).Unmarshal(frame); err == nil { + t.Fatal("expected a wrapping sender id to be rejected before truncation") + } +} + +// A message dropped because a stream was full must NOT be recorded as seen: a +// drop must not poison the dedup set against a later re-delivery of the same +// content. (Standard pkg/net retransmissions are filtered upstream and would not +// re-reach the handler, so the real protection against losing an honest message +// is the buffer sizing; this guards only the dedup bookkeeping for any +// non-retransmit re-delivery.) +func TestBroadcastChannelRunnerBus_DropDoesNotPoisonDedup(t *testing.T) { + f := newRunnerBusAuthFixture(t, 1) // buffer of one + sub := f.bus.Subscribe() + + first := shareMessage(1, f.operatorA, "first") + second := shareMessage(1, f.operatorA, "second") // distinct body, distinct hash + + f.bus.handleMessage(first) // fills the single-slot buffer + f.bus.handleMessage(second) // buffer full -> dropped (must stay un-seen) + + if got := <-sub.Shares(); string(got.Payload) != "first" { + t.Fatalf("expected 'first' drained, got %q", got.Payload) + } + + // A re-delivery of the dropped content, now that the buffer has room, is + // accepted (not suppressed as a duplicate). + f.bus.handleMessage(second) + select { + case got := <-sub.Shares(): + if string(got.Payload) != "second" { + t.Fatalf("expected 'second' on re-delivery, got %q", got.Payload) + } + default: + t.Fatal("a message dropped on overflow must not be suppressed on a later re-delivery") + } +} + +func TestBroadcastChannelRunnerBus_DropsWhenStreamFullWithoutBlocking(t *testing.T) { + f := newRunnerBusAuthFixture(t, 2) // tiny stream buffer + sub := f.bus.Subscribe() + + // Deliver more distinct (body-different) shares than the buffer holds. Each + // must not block; the excess is dropped (newest). + for i := 0; i < 5; i++ { + f.bus.handleMessage(shareMessage(1, f.operatorA, string(rune('a'+i)))) + } + + count := 0 + for { + select { + case <-sub.Shares(): + count++ + continue + default: + } + break + } + if count != 2 { + t.Fatalf("expected the stream bounded to 2, got [%d] (and Broadcast must not have blocked)", count) + } +} + +func TestRunnerTransportMessage_RoundTrip(t *testing.T) { + original := &runnerTransportMessage{ + messageType: RunnerMsgSigningPackage, + sender: 3, + attempt: [attemptContextHashLength]byte{0x42, 0x99, 0xff}, + payload: []byte("signed-signing-package-envelope"), + } + + data, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // The registered unmarshaler presets messageType (it is not on the wire). + decoded := &runnerTransportMessage{messageType: RunnerMsgSigningPackage} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.sender != original.sender { + t.Fatalf("sender: got [%d], want [%d]", decoded.sender, original.sender) + } + if decoded.attempt != original.attempt { + t.Fatalf("attempt mismatch: got [%x], want [%x]", decoded.attempt, original.attempt) + } + if !bytes.Equal(decoded.payload, original.payload) { + t.Fatalf("payload mismatch: got [%q], want [%q]", decoded.payload, original.payload) + } + if decoded.Type() != "frost/roast_runner/signing_package" { + t.Fatalf("unexpected wire type: [%s]", decoded.Type()) + } +} + +func TestRunnerTransportMessage_EmptyPayloadRoundTrips(t *testing.T) { + original := &runnerTransportMessage{messageType: RunnerMsgCommitments, sender: 1} + data, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &runnerTransportMessage{messageType: RunnerMsgCommitments} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.sender != 1 || len(decoded.payload) != 0 { + t.Fatalf("unexpected decode: sender [%d], payload len [%d]", decoded.sender, len(decoded.payload)) + } +} + +func TestRunnerTransportMessage_RejectsInvalid(t *testing.T) { + // Zero sender on marshal. + if _, err := (&runnerTransportMessage{messageType: RunnerMsgCommitments, sender: 0}).Marshal(); err == nil { + t.Fatal("expected marshal to reject a zero sender") + } + // Shorter than the fixed header. + if err := (&runnerTransportMessage{}).Unmarshal([]byte{0x00, 0x01}); err == nil { + t.Fatal("expected unmarshal to reject a short message") + } + // Exactly the header but a zero sender (all-zero prefix). + if err := (&runnerTransportMessage{}).Unmarshal(make([]byte, 4+attemptContextHashLength)); err == nil { + t.Fatal("expected unmarshal to reject a zero sender") + } +} + +func TestRunnerTransportType_CoversEveryStream(t *testing.T) { + // Every type the subscriber demuxes must have a distinct wire type string, + // else BroadcastChannel cannot dispatch it. + types := []RunnerMessageType{ + RunnerMsgCommitments, + RunnerMsgSigningPackage, + RunnerMsgShareSubmission, + RunnerMsgEvidenceSnapshot, + RunnerMsgTransitionBundle, + } + seen := map[string]struct{}{} + for _, mt := range types { + s := runnerTransportType[mt] + if s == "" { + t.Fatalf("runner message type [%v] has no wire type string", mt) + } + if _, dup := seen[s]; dup { + t.Fatalf("wire type string [%s] is not distinct", s) + } + seen[s] = struct{}{} + } +} + +func TestNewBroadcastChannelRunnerBus_RejectsNilDependencies(t *testing.T) { + ctx := context.Background() + // nil context + if _, err := NewBroadcastChannelRunnerBus(nil, nil, nil, nil); err == nil { //nolint:staticcheck + t.Fatal("expected nil context to be rejected") + } + // nil channel (other deps non-nil-checked after) + if _, err := NewBroadcastChannelRunnerBus(ctx, nil, nil, nil); err == nil { + t.Fatal("expected nil channel to be rejected") + } +} diff --git a/pkg/frost/signing/roast_runner_engine_frost_native.go b/pkg/frost/signing/roast_runner_engine_frost_native.go new file mode 100644 index 0000000000..e4e556210e --- /dev/null +++ b/pkg/frost/signing/roast_runner_engine_frost_native.go @@ -0,0 +1,87 @@ +//go:build frost_native + +package signing + +// interactiveSigningEngine is the slice of the native tbtc-signer engine the +// interactive runner drives for one attempt. It is defined at the runner +// boundary (interface segregation) so the runner is exercised with a +// programmable fake under frost_native alone - no cgo, deterministic - per the +// design consult. The cgo-backed buildTaggedTBTCSignerEngine satisfies it; that +// satisfaction is asserted in the cgo wiring layer, not here, so this file does +// not pull in frost_tbtc_signer && cgo. +// +// Secret nonces never cross this boundary: the engine generates, holds, and +// zeroizes them keyed by (session_id, attempt_id); the runner exchanges only +// the public commitments, the coordinator's signing package, and the signature +// shares returned here. +type interactiveSigningEngine interface { + // DeriveInteractiveAttemptContext derives the canonical attempt context + // (coordinator, included-participants fingerprint, attempt id) and the + // per-participant FROST identifiers from the attempt's public inputs, so the + // runner never re-implements the engine's domain-separated derivations. The + // runner cross-checks the returned coordinator/included set against the + // binding's own RFC-21 election before opening. + DeriveInteractiveAttemptContext( + sessionID string, + message []byte, + keyGroup string, + threshold uint16, + attemptNumber uint32, + includedParticipants []uint16, + ) (*NativeDeriveInteractiveAttemptContextResult, error) + + // InteractiveSessionOpen opens (or idempotently re-opens) the attempt and + // returns the engine's canonical attempt id. + InteractiveSessionOpen( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + threshold uint16, + taprootMerkleRoot *[32]byte, + attemptContext NativeInteractiveAttemptContext, + ) (*NativeInteractiveSessionOpenResult, error) + + // InteractiveRound1 returns this member's public round-1 commitments. + InteractiveRound1( + sessionID string, + attemptID string, + memberIdentifier uint16, + ) ([]byte, error) + + // NewSigningPackage builds the FROST signing package from the responsive + // subset's commitments (the elected coordinator calls this). + NewSigningPackage( + message []byte, + commitments []nativeFROSTCommitment, + ) ([]byte, error) + + // InteractiveRound2 consumes this member's nonces against the coordinator's + // signing package and returns its signature share. + InteractiveRound2( + sessionID string, + attemptID string, + memberIdentifier uint16, + signingPackage []byte, + ) ([]byte, error) + + // InteractiveAggregate aggregates the collected shares into the BIP-340 + // signature, or fails (with an InteractiveAggregateShareVerificationError + // carrying candidate culprits) when a share does not verify. + InteractiveAggregate( + sessionID string, + attemptID string, + signingPackage []byte, + signatureShares []nativeFROSTSignatureShare, + taprootMerkleRoot *[32]byte, + ) ([]byte, error) + + // InteractiveSessionAbort tells the engine to drop the attempt's held + // secret nonces/session state. The runner defers it for early exits so an + // attempt abandoned before aggregation does not leave nonce material + // resident. + InteractiveSessionAbort( + sessionID string, + attemptID *string, + ) (*NativeInteractiveSessionAbortResult, error) +} diff --git a/pkg/frost/signing/roast_runner_engine_frost_native_test.go b/pkg/frost/signing/roast_runner_engine_frost_native_test.go new file mode 100644 index 0000000000..45b9eb9e94 --- /dev/null +++ b/pkg/frost/signing/roast_runner_engine_frost_native_test.go @@ -0,0 +1,239 @@ +//go:build frost_native + +package signing + +import ( + "fmt" + "sync" + "testing" +) + +// Compile-time assertion that the fake satisfies the runner's engine boundary. +var _ interactiveSigningEngine = (*fakeInteractiveSigningEngine)(nil) + +// fakeInteractiveSigningEngine is a programmable interactiveSigningEngine for +// runner tests. It returns configured (non-crypto) responses and records call +// counts so the runner's orchestration is exercised deterministically without +// cgo or real FROST - the real engine's crypto is covered by the engine's own +// suite. Per-member commitments/shares default to member-derived bytes so each +// member's contribution is distinct; the aggregate result (or error) is +// programmable for the happy and, later, the sad path. +type fakeInteractiveSigningEngine struct { + attemptID string + idempotent bool + signingPackage []byte + signature []byte + aggregateErr error + // coordinatorIdentifier is what DeriveInteractiveAttemptContext returns; the + // harness sets it to the binding's elected coordinator so the runner's + // cross-check passes (a real engine derives the same value the binding did). + coordinatorIdentifier uint16 + deriveErr error + + commitmentsByMember map[uint16][]byte + shareByMember map[uint16][]byte + + mu sync.Mutex + openCalls int + round1Calls int + newPackageCalls int + round2Calls int + aggregateCalls int + abortCalls int + deriveCalls int + lastAggregateShares []nativeFROSTSignatureShare + lastNewPackageCommitments []nativeFROSTCommitment +} + +func (f *fakeInteractiveSigningEngine) DeriveInteractiveAttemptContext( + sessionID string, + message []byte, + keyGroup string, + threshold uint16, + attemptNumber uint32, + includedParticipants []uint16, +) (*NativeDeriveInteractiveAttemptContextResult, error) { + f.mu.Lock() + f.deriveCalls++ + f.mu.Unlock() + if f.deriveErr != nil { + return nil, f.deriveErr + } + identifiers := make([]NativeFROSTParticipantIdentifier, 0, len(includedParticipants)) + for _, participant := range includedParticipants { + identifiers = append(identifiers, NativeFROSTParticipantIdentifier{ + ParticipantIdentifier: participant, + FrostIdentifier: fmt.Sprintf("frost-id-%d", participant), + }) + } + return &NativeDeriveInteractiveAttemptContextResult{ + AttemptContext: NativeInteractiveAttemptContext{ + AttemptNumber: attemptNumber, + CoordinatorIdentifier: f.coordinatorIdentifier, + IncludedParticipants: append([]uint16(nil), includedParticipants...), + IncludedParticipantsFingerprint: "fake-fingerprint", + AttemptID: "fake-attempt-id", + }, + FrostIdentifiers: identifiers, + }, nil +} + +func (f *fakeInteractiveSigningEngine) InteractiveSessionAbort( + sessionID string, + attemptID *string, +) (*NativeInteractiveSessionAbortResult, error) { + f.mu.Lock() + f.abortCalls++ + f.mu.Unlock() + return &NativeInteractiveSessionAbortResult{SessionID: sessionID, Aborted: true}, nil +} + +func (f *fakeInteractiveSigningEngine) abortCallCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.abortCalls +} + +func (f *fakeInteractiveSigningEngine) round2CallCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.round2Calls +} + +// newPackageCommitments returns a copy of the commitments the engine last built a +// signing package over - the chosen t-subset for the coordinator. +func (f *fakeInteractiveSigningEngine) newPackageCommitments() []nativeFROSTCommitment { + f.mu.Lock() + defer f.mu.Unlock() + return append([]nativeFROSTCommitment(nil), f.lastNewPackageCommitments...) +} + +func newFakeInteractiveSigningEngine() *fakeInteractiveSigningEngine { + return &fakeInteractiveSigningEngine{ + attemptID: "attempt-1", + signingPackage: []byte("fake-signing-package"), + signature: []byte("fake-bip340-signature"), + commitmentsByMember: map[uint16][]byte{}, + shareByMember: map[uint16][]byte{}, + } +} + +func (f *fakeInteractiveSigningEngine) memberCommitments(member uint16) []byte { + if c, ok := f.commitmentsByMember[member]; ok { + return c + } + return []byte(fmt.Sprintf("commitments-%d", member)) +} + +func (f *fakeInteractiveSigningEngine) memberShare(member uint16) []byte { + if s, ok := f.shareByMember[member]; ok { + return s + } + return []byte(fmt.Sprintf("share-%d", member)) +} + +func (f *fakeInteractiveSigningEngine) InteractiveSessionOpen( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + threshold uint16, + taprootMerkleRoot *[32]byte, + attemptContext NativeInteractiveAttemptContext, +) (*NativeInteractiveSessionOpenResult, error) { + f.mu.Lock() + f.openCalls++ + f.mu.Unlock() + return &NativeInteractiveSessionOpenResult{ + SessionID: sessionID, + AttemptID: f.attemptID, + Idempotent: f.idempotent, + }, nil +} + +func (f *fakeInteractiveSigningEngine) InteractiveRound1( + sessionID string, + attemptID string, + memberIdentifier uint16, +) ([]byte, error) { + f.mu.Lock() + f.round1Calls++ + f.mu.Unlock() + return f.memberCommitments(memberIdentifier), nil +} + +func (f *fakeInteractiveSigningEngine) NewSigningPackage( + message []byte, + commitments []nativeFROSTCommitment, +) ([]byte, error) { + f.mu.Lock() + f.newPackageCalls++ + f.lastNewPackageCommitments = append([]nativeFROSTCommitment(nil), commitments...) + f.mu.Unlock() + return f.signingPackage, nil +} + +func (f *fakeInteractiveSigningEngine) InteractiveRound2( + sessionID string, + attemptID string, + memberIdentifier uint16, + signingPackage []byte, +) ([]byte, error) { + f.mu.Lock() + f.round2Calls++ + f.mu.Unlock() + return f.memberShare(memberIdentifier), nil +} + +func (f *fakeInteractiveSigningEngine) InteractiveAggregate( + sessionID string, + attemptID string, + signingPackage []byte, + signatureShares []nativeFROSTSignatureShare, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + f.mu.Lock() + f.aggregateCalls++ + f.lastAggregateShares = signatureShares + f.mu.Unlock() + if f.aggregateErr != nil { + return nil, f.aggregateErr + } + return f.signature, nil +} + +func TestFakeInteractiveSigningEngine_Programmable(t *testing.T) { + engine := newFakeInteractiveSigningEngine() + + open, err := engine.InteractiveSessionOpen("s", 1, []byte("m"), "kg", 2, nil, NativeInteractiveAttemptContext{}) + if err != nil || open.AttemptID != "attempt-1" { + t.Fatalf("unexpected open result: %+v err=%v", open, err) + } + + // Per-member round-1/round-2 bytes are distinct by default. + c1, _ := engine.InteractiveRound1("s", "a", 1) + c2, _ := engine.InteractiveRound1("s", "a", 2) + if string(c1) == string(c2) { + t.Fatal("expected distinct per-member commitments") + } + + pkg, _ := engine.NewSigningPackage([]byte("m"), nil) + if string(pkg) != "fake-signing-package" { + t.Fatalf("unexpected signing package: %s", pkg) + } + + sig, err := engine.InteractiveAggregate("s", "a", pkg, nil, nil) + if err != nil || string(sig) != "fake-bip340-signature" { + t.Fatalf("unexpected aggregate result: %s err=%v", sig, err) + } + + // A programmed aggregate error surfaces (the later sad path uses this). + engine.aggregateErr = fmt.Errorf("boom") + if _, err := engine.InteractiveAggregate("s", "a", pkg, nil, nil); err == nil { + t.Fatal("expected the programmed aggregate error") + } + + if engine.openCalls != 1 || engine.round1Calls != 2 || engine.newPackageCalls != 1 || engine.aggregateCalls != 2 { + t.Fatalf("unexpected call counts: %+v", engine) + } +} diff --git a/pkg/frost/signing/roast_runner_frost_native.go b/pkg/frost/signing/roast_runner_frost_native.go new file mode 100644 index 0000000000..fd4d87d4ac --- /dev/null +++ b/pkg/frost/signing/roast_runner_frost_native.go @@ -0,0 +1,807 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "fmt" + "sort" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// interactiveSigningRunner drives one node's participation in ONE interactive +// signing attempt over a RunnerBus: round-1 commitments, the coordinator's +// signing package, round-2 shares, and aggregation - terminating in +// MarkSucceeded + the BIP-340 signature on the happy path. The blame path (on +// an aggregate share-verification failure) lands separately. +// +// Every value the runner consumes for engine/collector calls is derived from +// the immutable ActiveRoastAttempt binding, never from peer messages, and the +// node records its OWN produced share into the collector explicitly rather than +// relying on bus self-echo. +// +// Transport assumptions - the in-process test bus meets the first; the real +// pkg/net transport MUST meet both: +// 1. RunnerMessage.Sender is the AUTHENTICATED peer identity. The sender==elected +// and SubmitterID==Sender filters, and per-member commitment slotting, are +// only as sound as that authentication. +// 2. Delivery must not let a slow or flooding peer block an honest broadcaster +// indefinitely. The runner does not fully drain every stream - it bounds the +// equivocation drains, the coordinator never reads its own package stream, +// and for t-of-included finalize the coordinator stops reading commitments +// once t have arrived while non-coordinators never read commitments at all - +// so the transport must apply backpressure or drop, never block forever, on +// an undrained or oversubscribed stream. +// +// Round-1 commitments are unsigned: with authenticated senders the worst case is +// a member's own bad or equivocated commitment, which surfaces as a round-2 +// mismatch and retry, never a cross-member poison or signing breach. Blaming +// commitment equivocation would need signed commitments - a protocol decision +// for the design consult. +type interactiveSigningRunner struct { + attempt *ActiveRoastAttempt + member group.MemberIndex + // messageDigest is the message FROST signs: the binding's MessageDigest, NOT + // a separate caller parameter, so the runner can never open/aggregate a + // message inconsistent with the attempt it is bound to. + messageDigest []byte + threshold uint16 + // includedMembers is the attempt's included set as a lookup, cached at + // construction. It gates which shares the collector retains as evidence (any + // included member's, even a non-signer observer's divergent share), distinct + // from the per-attempt signer set that gates which shares count toward the + // aggregate. + includedMembers map[group.MemberIndex]struct{} + engine interactiveSigningEngine + collector *roast.Round2Collector + coordinator roast.Coordinator + signer roast.Signer + bus RunnerBus + // sub is established at construction (before any Run broadcasts) so a node + // never misses a peer message broadcast before it subscribed. + sub *RunnerBusSubscriber +} + +func newInteractiveSigningRunner( + attempt *ActiveRoastAttempt, + member group.MemberIndex, + threshold uint16, + engine interactiveSigningEngine, + collector *roast.Round2Collector, + coordinator roast.Coordinator, + signer roast.Signer, + bus RunnerBus, +) (*interactiveSigningRunner, error) { + switch { + case attempt == nil: + return nil, fmt.Errorf("roast runner: active attempt is nil") + case engine == nil: + return nil, fmt.Errorf("roast runner: engine is nil") + case collector == nil: + return nil, fmt.Errorf("roast runner: collector is nil") + case coordinator == nil: + return nil, fmt.Errorf("roast runner: coordinator is nil") + case signer == nil: + return nil, fmt.Errorf("roast runner: signer is nil") + case bus == nil: + return nil, fmt.Errorf("roast runner: bus is nil") + case threshold == 0: + return nil, fmt.Errorf("roast runner: threshold is zero") + } + attemptCtx := attempt.Context() + if !memberInSet(member, attemptCtx.IncludedSet) { + return nil, fmt.Errorf( + "roast runner: member %d is not in the attempt's included set", member, + ) + } + // The coordinator's round-1 collection proceeds until t responsive committers, + // so t must not exceed the included set - otherwise the subset can never form + // and the coordinator would block to the ctx deadline on every attempt. A + // well-formed attempt always selects at least threshold members; fail fast at + // construction rather than silently degrade into timeout-driven retries. + if int(threshold) > len(attemptCtx.IncludedSet) { + return nil, fmt.Errorf( + "roast runner: threshold [%d] exceeds the attempt's included set size [%d]", + threshold, len(attemptCtx.IncludedSet), + ) + } + // The signed message is the binding's MessageDigest, derived here rather than + // accepted as a parameter: a caller-supplied message that diverged from the + // digest the attempt (and its package/share envelopes) is bound to could mark + // an attempt for digest A succeeded with a signature over digest B. + return &interactiveSigningRunner{ + attempt: attempt, + member: member, + messageDigest: append([]byte(nil), attemptCtx.MessageDigest[:]...), + threshold: threshold, + includedMembers: setOf(attemptCtx.IncludedSet), + engine: engine, + collector: collector, + coordinator: coordinator, + signer: signer, + bus: bus, + sub: bus.Subscribe(), + }, nil +} + +// Run executes the happy-path interactive signing flow for this node and +// returns the aggregated signature on success. It subscribes to the bus before +// broadcasting so no peer message is missed, and honors ctx cancellation while +// collecting commitments and shares. +func (r *interactiveSigningRunner) Run(ctx context.Context) ([]byte, error) { + binding := r.attempt + attemptCtx := binding.Context() + includedSet := attemptCtx.IncludedSet + contextHash := binding.ContextHash() + elected := binding.ElectedCoordinator() + + // 1. Derive the canonical attempt context + per-participant FROST identifiers + // from the engine (single source of truth - the runner never re-implements the + // engine's domain-separated derivations). Cross-check the engine-derived + // coordinator and included set against the binding's own RFC-21 election so a + // divergence fails closed HERE rather than producing a signature bound to the + // wrong coordinator/set; the two independent derivations must agree. + derived, err := r.engine.DeriveInteractiveAttemptContext( + binding.SessionID(), + r.messageDigest, + attemptCtx.KeyGroupID, + r.threshold, + attemptCtx.AttemptNumber, + includedSetToUint16(includedSet), + ) + if err != nil { + return nil, fmt.Errorf("roast runner: derive attempt context: %w", err) + } + // Compare in uint16 space (widening the uint8 elected is lossless): a + // truncating group.MemberIndex(...) cast would let a malformed engine + // coordinator > 255 alias an honest member (e.g. 257 -> 1) and falsely match. + if derived.AttemptContext.CoordinatorIdentifier != uint16(elected) { + return nil, fmt.Errorf( + "roast runner: engine-derived coordinator [%d] does not match the bound elected coordinator [%d]", + derived.AttemptContext.CoordinatorIdentifier, elected, + ) + } + if !sameMemberSet(derived.AttemptContext.IncludedParticipants, includedSet) { + return nil, fmt.Errorf("roast runner: engine-derived included set diverges from the bound attempt") + } + frostIdentifiers, err := frostIdentifierMap(derived.FrostIdentifiers, includedSet) + if err != nil { + return nil, fmt.Errorf("roast runner: %w", err) + } + + // 2. Open the interactive session with the engine-derived context; the engine + // returns the attempt id used for every subsequent round. + open, err := r.engine.InteractiveSessionOpen( + binding.SessionID(), + uint16(r.member), + r.messageDigest, + attemptCtx.KeyGroupID, + r.threshold, + binding.TaprootMerkleRoot(), + derived.AttemptContext, + ) + if err != nil { + return nil, fmt.Errorf("roast runner: open session: %w", err) + } + attemptID := open.AttemptID + + // Cleanup on conclusion, by outcome: + // - SUCCESS as a SIGNER: prune this attempt's round-2 collector state per the + // prune-on-conclusion contract (nothing needs it), so a collector reused + // across attempts does not retain concluded attempts indefinitely. Round 2 + // already consumed our round-1 nonces, so no abort is needed. + // - SUCCESS as an OBSERVER: a member that committed in round 1 but was not in + // the chosen signing subset obtains the signature by aggregating the + // subset's broadcast shares WITHOUT signing, so its round-1 nonces are + // still resident in the engine. Prune the collector AND abort the session + // to drop those nonces - the success branch otherwise suppresses the abort. + // - FAILURE / early exit: abort the engine session so it drops this attempt's + // resident secret nonces, but do NOT prune the collector. A failure path + // (the root-divergence return below, or an aggregate share-verification + // failure) retains signed evidence that the blame/retry path must still + // read via CoordinatorPackageProofs / ClassifyCandidateCulprits; the caller + // prunes after snapshotting or propagating it. + // Best-effort: neither may mask the run's real outcome. + succeeded := false + // signedRound2 records whether this node ran round 2 and thereby consumed its + // round-1 nonces; an observer never does, so it must still abort on success. + signedRound2 := false + defer func() { + if succeeded { + r.collector.PruneAttempt(contextHash[:]) + if !signedRound2 { + _, _ = r.engine.InteractiveSessionAbort(binding.SessionID(), &attemptID) + } + return + } + _, _ = r.engine.InteractiveSessionAbort(binding.SessionID(), &attemptID) + }() + + // 2. Begin collecting evidence for this attempt (elected coordinator from the + // binding, not a peer). + if err := r.collector.BeginAttempt(contextHash[:], elected, includedSet); err != nil { + return nil, fmt.Errorf("roast runner: collector begin attempt: %w", err) + } + + // 3. Round 1: our commitments, broadcast to the group (own kept locally). + ownCommitments, err := r.engine.InteractiveRound1(binding.SessionID(), attemptID, uint16(r.member)) + if err != nil { + return nil, fmt.Errorf("roast runner: round 1: %w", err) + } + r.broadcast(RunnerMsgCommitments, contextHash, ownCommitments) + + // 4. Only the elected coordinator collects commitments - it alone builds the + // signing package. It gathers the first t responsive committers (its own + // already seeded) and builds the package over exactly that subset, so a member + // past the t-th to commit (slow or offline) never stalls the attempt. Every + // other member broadcast its own commitment above and now awaits the package. + commitments := map[group.MemberIndex][]byte{r.member: ownCommitments} + if r.member == elected { + if err := r.collectCommitments(ctx, r.sub.Commitments(), contextHash, includedSet, commitments); err != nil { + return nil, fmt.Errorf("roast runner: collect commitments: %w", err) + } + } + + // 5. The elected coordinator builds, signs, and broadcasts the signing + // package; everyone else awaits it. + signingPackageEnvelope, err := r.obtainSigningPackage(ctx, r.sub.SigningPackages(), elected, contextHash, commitments, includedSet, frostIdentifiers) + if err != nil { + return nil, err + } + pkg := &roast.SigningPackage{} + if err := pkg.Unmarshal(signingPackageEnvelope); err != nil { + return nil, fmt.Errorf("roast runner: unmarshal signing package: %w", err) + } + // Authenticate + retain the coordinator-signed package (Q1 boundary lives in + // the collector, not here). + if err := r.collector.RecordSigningPackage(pkg); err != nil { + return nil, fmt.Errorf("roast runner: record signing package: %w", err) + } + // Retain any FURTHER body-different packages the elected coordinator already + // broadcast for this attempt: the authoritative one is recorded above, so + // these record as coordinator equivocation (EquivocationKindSigningPackageConflict, + // surfaced via CoordinatorPackageProofs). obtainSigningPackage returns on the + // first package, so without this the duplicates the bus deliberately preserves + // would be lost. Non-coordinators only - the coordinator does not receive its + // own broadcast. + if r.member != elected { + r.recordBufferedCoordinatorPackages(r.sub.SigningPackages(), elected, contextHash) + } + // Refuse a package whose taproot root diverges from the bound root: signing + // Round 2 against it would sign for the WRONG tweak. The package was retained + // above as evidence for the blame path; we just must not sign it. + if !r.taprootRootMatches(pkg.TaprootMerkleRoot) { + return nil, fmt.Errorf("roast runner: signing package taproot root diverges from the bound session root") + } + + // 6. The package names the chosen signing subset (the first t responsive + // committers the coordinator built it over). A local member IN that subset is + // a signer; one NOT in it is an OBSERVER - it committed in round 1 but was not + // chosen, so it does not sign and only aggregates the subset's broadcast + // shares. An empty signer set is the full-included flow (back-compat / no + // oversizing): every included member signs. SignerIDs() is safe here - + // Unmarshal and AuthenticateSigningPackage (via RecordSigningPackage) both + // Validate, so each id is a real, ascending, distinct member index. + signerSet := pkg.SignerIDs() + if len(signerSet) == 0 { + signerSet = includedSet + } + + // 7. Round 2 (signers only): our signature share, recorded locally and + // broadcast. An observer skips round 2 entirely, leaving its round-1 nonces + // resident - the cleanup defer aborts them on success. + shares := map[group.MemberIndex][]byte{} + if memberInSet(r.member, signerSet) { + ownShare, err := r.engine.InteractiveRound2(binding.SessionID(), attemptID, uint16(r.member), pkg.SigningPackageBytes) + if err != nil { + return nil, fmt.Errorf("roast runner: round 2: %w", err) + } + // Round 2 consumed our round-1 nonces: a successful signer prunes without + // aborting; only a non-signing observer still needs the abort. + signedRound2 = true + ownSubmission, ownSubmissionEnvelope, err := r.signShareSubmission(pkg, contextHash, elected, ownShare) + if err != nil { + return nil, err + } + if err := r.collector.RecordShareSubmission(ownSubmission); err != nil { + return nil, fmt.Errorf("roast runner: record own share submission: %w", err) + } + r.broadcast(RunnerMsgShareSubmission, contextHash, ownSubmissionEnvelope) + shares[r.member] = ownShare + } + + // 8. Collect a round-2 share from every member in the chosen signing subset (a + // signer already has its own; an observer collects all t), as inner FROST + // share bytes. A subset signer that never broadcasts a share stalls the + // attempt to the ctx deadline -> fail -> the existing ROAST retry path. + if err := r.collectShares(ctx, r.sub.Shares(), contextHash, signerSet, shares); err != nil { + return nil, fmt.Errorf("roast runner: collect shares: %w", err) + } + + // 9. Aggregate the subset's shares. Aggregation is a public operation over the + // package and the t broadcast shares, so signers and observers alike obtain + // the same signature; an observer aggregates against its own open session + // without having contributed a share. A share-verification failure surfaces + // the typed error with candidate culprits for the (separate) blame path. + signature, err := r.engine.InteractiveAggregate( + binding.SessionID(), + attemptID, + pkg.SigningPackageBytes, + toFrostSignatureShares(shares, frostIdentifiers), + binding.TaprootMerkleRoot(), + ) + if err != nil { + return nil, fmt.Errorf("roast runner: aggregate: %w", err) + } + + // 10. Mark the attempt succeeded so the cleanup path produces no transition + // bundle for a completed attempt. + if err := r.coordinator.MarkSucceeded(binding.Handle()); err != nil { + return nil, fmt.Errorf("roast runner: mark succeeded: %w", err) + } + // The attempt is finalized; the cleanup defer prunes. A signer's nonces are + // spent so it does not abort; an observer aborts via the !signedRound2 branch. + succeeded = true + return signature, nil +} + +func (r *interactiveSigningRunner) broadcast(t RunnerMessageType, attempt [attempt.MessageDigestLength]byte, payload []byte) { + r.bus.Broadcast(RunnerMessage{Type: t, Sender: r.member, Attempt: attempt, Payload: payload}) +} + +// obtainSigningPackage returns the attempt's signing-package envelope: built and +// broadcast locally when this node is the elected coordinator, or awaited from +// the bus otherwise. +func (r *interactiveSigningRunner) obtainSigningPackage( + ctx context.Context, + stream <-chan RunnerMessage, + elected group.MemberIndex, + contextHash [attempt.MessageDigestLength]byte, + commitments map[group.MemberIndex][]byte, + includedSet []group.MemberIndex, + frostIdentifiers map[group.MemberIndex]string, +) ([]byte, error) { + if r.member != elected { + // Accept ONLY the elected coordinator's package for THIS attempt. Without + // this, any node could broadcast a garbage package; the honest member + // would forward it to RecordSigningPackage, fail authentication, and abort + // its run before the real package ever arrives. + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case msg := <-stream: + if msg.Attempt != contextHash || msg.Sender != elected { + continue + } + // Trust the SIGNED attempt hash, not just the unsigned outer bus + // field. A package the coordinator legitimately signed for ANOTHER + // attempt, rewrapped in a current-attempt message, must not drive + // this flow - it could be recorded/authenticated under that other + // attempt (if live in this shared collector) and we would sign the + // wrong package. Keep waiting for the package signed for THIS attempt. + pkg := &roast.SigningPackage{} + if err := pkg.Unmarshal(msg.Payload); err != nil { + continue + } + if !bytes.Equal(pkg.AttemptContextHash, contextHash[:]) { + continue + } + return append([]byte(nil), msg.Payload...), nil + } + } + } + + frostPackage, err := r.engine.NewSigningPackage(r.messageDigest, toFrostCommitments(commitments, includedSet, frostIdentifiers)) + if err != nil { + return nil, fmt.Errorf("roast runner: new signing package: %w", err) + } + // The chosen signing subset is exactly the members whose commitments the + // package was built over (the first t responsive committers). Carry it in + // signer_ids so non-coordinators learn which members to await shares from and + // which committed members are observers. The FROST SigningPackageBytes is the + // cryptographic source of truth, so this is a liveness hint, never blame. + signerIDs := sortedMemberIndices(commitments) + envelope, err := r.signSigningPackage(contextHash, elected, signerIDs, frostPackage) + if err != nil { + return nil, err + } + r.broadcast(RunnerMsgSigningPackage, contextHash, envelope) + return envelope, nil +} + +func (r *interactiveSigningRunner) signSigningPackage( + contextHash [attempt.MessageDigestLength]byte, + elected group.MemberIndex, + signerIDs []group.MemberIndex, + frostPackage []byte, +) ([]byte, error) { + pkg := &roast.SigningPackage{ + AttemptContextHash: append([]byte(nil), contextHash[:]...), + CoordinatorIDValue: uint32(elected), + SigningPackageBytes: frostPackage, + SignerIDsValue: memberIndicesToUint32(signerIDs), + } + if root := r.attempt.TaprootMerkleRoot(); root != nil { + pkg.TaprootMerkleRoot = append([]byte(nil), root[:]...) + } + payload, err := pkg.SignableBytes() + if err != nil { + return nil, fmt.Errorf("roast runner: signing package signable bytes: %w", err) + } + sig, err := r.signer.Sign(payload) + if err != nil { + return nil, fmt.Errorf("roast runner: sign signing package: %w", err) + } + pkg.CoordinatorSignature = sig + envelope, err := pkg.Marshal() + if err != nil { + return nil, fmt.Errorf("roast runner: marshal signing package: %w", err) + } + return envelope, nil +} + +func (r *interactiveSigningRunner) signShareSubmission( + pkg *roast.SigningPackage, + contextHash [attempt.MessageDigestLength]byte, + elected group.MemberIndex, + share []byte, +) (*roast.ShareSubmission, []byte, error) { + packageHash, err := pkg.BodyHash() + if err != nil { + return nil, nil, fmt.Errorf("roast runner: signing package body hash: %w", err) + } + sub := &roast.ShareSubmission{ + AttemptContextHash: append([]byte(nil), contextHash[:]...), + SubmitterIDValue: uint32(r.member), + CoordinatorIDValue: uint32(elected), + SigningPackageHash: append([]byte(nil), packageHash[:]...), + SignatureShare: append([]byte(nil), share...), + } + payload, err := sub.SignableBytes() + if err != nil { + return nil, nil, fmt.Errorf("roast runner: share submission signable bytes: %w", err) + } + sig, err := r.signer.Sign(payload) + if err != nil { + return nil, nil, fmt.Errorf("roast runner: sign share submission: %w", err) + } + sub.SubmitterSignature = sig + envelope, err := sub.Marshal() + if err != nil { + return nil, nil, fmt.Errorf("roast runner: marshal share submission: %w", err) + } + return sub, envelope, nil +} + +// collectCommitments (coordinator only) fills `into` with the first t responsive +// committers' round-1 commitments - the coordinator's own already seeded, then +// the first t-1 included peers to arrive - and STOPS at t (r.threshold). The +// coordinator builds the FROST package over exactly this t-subset, so a member +// past the t-th to commit (slow or offline) never stalls the attempt: collection +// proceeds the instant t have committed. If ctx expires first (fewer than t ever +// commit), the run fails into the existing ROAST retry path. t <= len(included) +// always, so the loop terminates once t included members have committed. +func (r *interactiveSigningRunner) collectCommitments( + ctx context.Context, + stream <-chan RunnerMessage, + contextHash [attempt.MessageDigestLength]byte, + includedSet []group.MemberIndex, + into map[group.MemberIndex][]byte, +) error { + included := setOf(includedSet) + for len(into) < int(r.threshold) { + select { + case <-ctx.Done(): + return ctx.Err() + case msg := <-stream: + // Ignore messages for another attempt (the bus may carry several), + // from non-included senders, and a sender already collected. Round-1 + // commitments are unsigned; a spoofed commitment for a member is + // caught engine-side at Round2, which byte-checks the member's own + // commitment against the package. + if msg.Attempt != contextHash { + continue + } + if _, want := included[msg.Sender]; !want { + continue + } + if _, have := into[msg.Sender]; have { + continue + } + into[msg.Sender] = append([]byte(nil), msg.Payload...) + } + } + return nil +} + +// collectShares fills `into` with each share from the chosen signing subset +// (`signerSet`) as inner FROST share bytes - a signer's own already seeded, an +// observer's none - counting the first accepted body per sender, and retains +// EVERY well-formed share in the collector (including a sender's later, +// body-different ones) which is where member equivocation is detected. It +// collects over the signer set, NOT the full included set: a committed member +// the coordinator did not choose is an observer that contributes no share, so +// waiting for it would stall every attempt that omitted an offline member. +func (r *interactiveSigningRunner) collectShares( + ctx context.Context, + stream <-chan RunnerMessage, + contextHash [attempt.MessageDigestLength]byte, + signerSet []group.MemberIndex, + into map[group.MemberIndex][]byte, +) error { + signers := setOf(signerSet) + for len(into) < len(signers) { + select { + case <-ctx.Done(): + return ctx.Err() + case msg := <-stream: + r.recordShareMessage(msg, contextHash, signers, into) + } + } + // `into` is full, but the slot-filling share may have body-different + // duplicates already queued behind it on the stream. Record ONLY those + // buffered at entry: the bound is the queue length now, so a peer that keeps + // the stream non-empty (e.g. flooding body-different shares) cannot starve a + // receive-until-empty loop and livelock the drain, stalling aggregation. Late + // arrivals are the blame path's concern. + for i, n := 0, len(stream); i < n; i++ { + select { + case msg := <-stream: + r.recordShareMessage(msg, contextHash, signers, into) + default: + return nil + } + } + return nil +} + +// recordShareMessage validates a share-submission bus message, retains it in the +// collector, and counts it toward `into` only when the sender is in the chosen +// signing subset and it is the sender's first accepted share. Retention is gated +// by INCLUDED-set membership, not the signer set: the collector deliberately +// keeps an included non-signer's DIVERGENT share - a targeted coordinator- +// equivocation victim that signed a different package - as +// EquivocationKindDivergentShare evidence for the f+1 blame/transition path, so +// the runner must hand any included member's well-formed share to the collector +// even though only signer-set shares count toward the aggregate. Recording BEFORE +// the already-collected check is what lets the collector observe member +// equivocation (a body-different second signed share -> +// EquivocationKindShareConflict / DivergentShare); a divergent / conflicting / +// unauthenticated share is retained where applicable but never counted. Retention +// is bounded (the collector keeps the first per submitter and only emits on the +// rest), and the bus delivers only body-different duplicates. +func (r *interactiveSigningRunner) recordShareMessage( + msg RunnerMessage, + contextHash [attempt.MessageDigestLength]byte, + signers map[group.MemberIndex]struct{}, + into map[group.MemberIndex][]byte, +) { + if msg.Attempt != contextHash { + return + } + // Retain shares from any INCLUDED member, not just the signing subset: an + // included non-signer's divergent share is targeted-equivocation evidence the + // collector keeps. An outsider (not in the included set) is dropped here rather + // than handed to the collector, which would reject it as not-included anyway. + if _, included := r.includedMembers[msg.Sender]; !included { + return + } + var sub roast.ShareSubmission + if err := sub.Unmarshal(msg.Payload); err != nil { + return + } + // Bind the authenticated transport sender to the claimed submitter: a node + // embedding another member's id would otherwise fill that honest member's + // slot with garbage, drop their real share, and get them falsely blamed. + if sub.SubmitterID() != msg.Sender { + return + } + // Trust the SIGNED attempt hash, not just the unsigned outer bus field. The + // collector keys RecordShareSubmission by sub.AttemptContextHash, so a share + // the submitter signed for ANOTHER attempt - rewrapped in a current-attempt + // message - would be recorded under that attempt (accepted, returning nil if + // it is live in this shared collector) and counted toward THIS aggregate. + if !bytes.Equal(sub.AttemptContextHash, contextHash[:]) { + return + } + recordErr := r.collector.RecordShareSubmission(&sub) + // Only the chosen signing subset's shares count toward this aggregate; a + // committed-but-unchosen observer's share is retained above as evidence but + // never counted. + if _, isSigner := signers[msg.Sender]; !isSigner { + return + } + if _, have := into[msg.Sender]; have { + return + } + if recordErr != nil { + return + } + into[msg.Sender] = append([]byte(nil), sub.SignatureShare...) +} + +// recordBufferedCoordinatorPackages records the signing packages the elected +// coordinator has already broadcast for this attempt that are buffered at entry, +// so the collector can surface coordinator equivocation. The caller MUST have +// recorded the authoritative package first, so a body-different one here records +// as the conflicting package (not the authoritative). Bounded by the queue +// length at entry so a flooding peer cannot livelock a receive-until-empty loop; +// continuous monitoring across a real transport is the blame path's concern. +// RecordSigningPackage authenticates the coordinator signature, so a +// forged-sender package is rejected rather than retained. +func (r *interactiveSigningRunner) recordBufferedCoordinatorPackages( + stream <-chan RunnerMessage, + elected group.MemberIndex, + contextHash [attempt.MessageDigestLength]byte, +) { + for i, n := 0, len(stream); i < n; i++ { + select { + case msg := <-stream: + if msg.Attempt != contextHash || msg.Sender != elected { + continue + } + pkg := &roast.SigningPackage{} + if err := pkg.Unmarshal(msg.Payload); err != nil { + continue + } + // Signed attempt hash, not the unsigned outer field (see + // obtainSigningPackage): never record a package signed for another + // attempt under it via this attempt's drain. + if !bytes.Equal(pkg.AttemptContextHash, contextHash[:]) { + continue + } + _ = r.collector.RecordSigningPackage(pkg) + default: + return + } + } +} + +// includedSetToUint16 converts the binding's member-index set to the u16 list the +// engine's DeriveInteractiveAttemptContext expects. +func includedSetToUint16(includedSet []group.MemberIndex) []uint16 { + out := make([]uint16, 0, len(includedSet)) + for _, m := range includedSet { + out = append(out, uint16(m)) + } + return out +} + +// sameMemberSet reports whether the engine-derived participant list is the same +// SET as the binding's included members - the cross-check that the engine's +// canonicalization agrees with the bound attempt, independent of order. It +// rejects duplicates in `derived` (consuming each expected member at most once), +// so a malformed list like [1,1] for included [1,2] does NOT falsely match +// despite the equal length. +func sameMemberSet(derived []uint16, included []group.MemberIndex) bool { + if len(derived) != len(included) { + return false + } + remaining := make(map[uint16]struct{}, len(included)) + for _, m := range included { + remaining[uint16(m)] = struct{}{} + } + for _, d := range derived { + if _, ok := remaining[d]; !ok { + return false + } + delete(remaining, d) + } + return len(remaining) == 0 +} + +// frostIdentifierMap indexes the engine-derived FROST identifiers by Go member +// index, requiring exactly one per included member. The engine returns one per +// participant and the bridge already verified the 1:1 correspondence, so a gap +// here is defensive against a future engine/bridge change. +func frostIdentifierMap( + entries []NativeFROSTParticipantIdentifier, + includedSet []group.MemberIndex, +) (map[group.MemberIndex]string, error) { + out := make(map[group.MemberIndex]string, len(entries)) + for _, entry := range entries { + out[group.MemberIndex(entry.ParticipantIdentifier)] = entry.FrostIdentifier + } + for _, m := range includedSet { + if out[m] == "" { + return nil, fmt.Errorf("missing FROST identifier for included member [%d]", m) + } + } + return out, nil +} + +// toFrostCommitments keys each collected commitment by the engine-derived FROST +// identifier for that member (frostIdentifiers), so NewSigningPackage builds the +// FROST BTreeMap the member's key share expects. +func toFrostCommitments( + commitments map[group.MemberIndex][]byte, + includedSet []group.MemberIndex, + frostIdentifiers map[group.MemberIndex]string, +) []nativeFROSTCommitment { + out := make([]nativeFROSTCommitment, 0, len(includedSet)) + for _, m := range includedSet { + data, ok := commitments[m] + if !ok { + continue + } + out = append(out, nativeFROSTCommitment{ + Identifier: frostIdentifiers[m], + Data: append([]byte(nil), data...), + }) + } + return out +} + +// toFrostSignatureShares keys each collected share by the engine-derived FROST +// identifier for that member, so InteractiveAggregate matches each share to the +// member's verifying share. +func toFrostSignatureShares( + shares map[group.MemberIndex][]byte, + frostIdentifiers map[group.MemberIndex]string, +) []nativeFROSTSignatureShare { + out := make([]nativeFROSTSignatureShare, 0, len(shares)) + for m, data := range shares { + out = append(out, nativeFROSTSignatureShare{ + Identifier: frostIdentifiers[m], + Data: append([]byte(nil), data...), + }) + } + return out +} + +// taprootRootMatches reports whether a received package's taproot root equals +// the bound session root, honoring key-path (nil bound root <-> empty package +// root) semantics. +func (r *interactiveSigningRunner) taprootRootMatches(packageRoot []byte) bool { + bound := r.attempt.TaprootMerkleRoot() + if bound == nil { + return len(packageRoot) == 0 + } + return bytes.Equal(packageRoot, bound[:]) +} + +func memberInSet(member group.MemberIndex, set []group.MemberIndex) bool { + for _, m := range set { + if m == member { + return true + } + } + return false +} + +func setOf(members []group.MemberIndex) map[group.MemberIndex]struct{} { + out := make(map[group.MemberIndex]struct{}, len(members)) + for _, m := range members { + out[m] = struct{}{} + } + return out +} + +// sortedMemberIndices returns the keys of a member-indexed map in ascending +// order - the chosen signing subset the coordinator carries in the package's +// signer_ids, which SigningPackage.Validate requires strictly ascending (the map +// keys are distinct, so sorting yields a strictly-ascending list). +func sortedMemberIndices(m map[group.MemberIndex][]byte) []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(m)) + for member := range m { + out = append(out, member) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// memberIndicesToUint32 widens an ascending member-index slice to the wire +// (uint32) form SigningPackage.SignerIDsValue carries. Widening uint8 -> +// uint32 is lossless, and ascending/distinct order is preserved. +func memberIndicesToUint32(members []group.MemberIndex) []uint32 { + out := make([]uint32, 0, len(members)) + for _, member := range members { + out = append(out, uint32(member)) + } + return out +} diff --git a/pkg/frost/signing/roast_runner_frost_native_test.go b/pkg/frost/signing/roast_runner_frost_native_test.go new file mode 100644 index 0000000000..7eea79b79c --- /dev/null +++ b/pkg/frost/signing/roast_runner_frost_native_test.go @@ -0,0 +1,1219 @@ +//go:build frost_native + +package signing + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// fixedTestSigner returns a fixed non-empty signature so the wire envelopes +// Marshal (they reject empty signatures); paired with NoOpSignatureVerifier it +// authenticates without real operator-key crypto. +type fixedTestSigner struct{} + +func (fixedTestSigner) Sign(_ []byte) ([]byte, error) { return []byte{0x01}, nil } + +type harness struct { + bus RunnerBus + runners []*interactiveSigningRunner + coords []roast.Coordinator + collectors []*roast.Round2Collector + engines []*fakeInteractiveSigningEngine + handles []roast.AttemptHandle + contextHash [attempt.MessageDigestLength]byte + includedSet []group.MemberIndex +} + +// buildInteractiveSigningHarness wires n nodes - each with its own coordinator, +// collector, and fake engine - to one shared in-process bus. Every node +// subscribes in its constructor, so all are wired before any Run broadcasts. +func buildInteractiveSigningHarness(t *testing.T, n int, threshold uint16) harness { + t.Helper() + included := make([]group.MemberIndex, 0, n) + for i := 1; i <= n; i++ { + included = append(included, group.MemberIndex(i)) + } + dkgKey := []byte{0x01, 0x02} + ctx, err := attempt.NewAttemptContext( + "session-1", "key-group-1", dkgKey, + [attempt.MessageDigestLength]byte{0x42}, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt context: %v", err) + } + + bus := NewInProcessRunnerBus(256) + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + + h := harness{bus: bus, contextHash: ctx.Hash(), includedSet: included} + for i := 0; i < n; i++ { + member := group.MemberIndex(i + 1) + coord := roast.NewInMemoryCoordinatorWithSigning(member, signer, verifier) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt (member %d): %v", member, err) + } + ara, err := NewActiveRoastAttempt(coord, handle, ctx, "session-1", nil, dkgKey) + if err != nil { + t.Fatalf("active attempt (member %d): %v", member, err) + } + collector := roast.NewRound2Collector(verifier) + engine := newFakeInteractiveSigningEngine() + // The engine derivation must agree with the binding's RFC-21 election, or + // the runner's cross-check fails closed (a real engine derives the same + // coordinator the binding did). + engine.coordinatorIdentifier = uint16(ara.ElectedCoordinator()) + runner, err := newInteractiveSigningRunner( + ara, member, threshold, + engine, + collector, + coord, signer, bus, + ) + if err != nil { + t.Fatalf("runner (member %d): %v", member, err) + } + h.coords = append(h.coords, coord) + h.collectors = append(h.collectors, collector) + h.engines = append(h.engines, engine) + h.handles = append(h.handles, handle) + h.runners = append(h.runners, runner) + } + return h +} + +// runAll runs every node concurrently and asserts each reaches a successful +// aggregate and transitions its attempt to Succeeded. +func (h harness) runAndAssertAllSucceed(t *testing.T) { + t.Helper() + runCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sigs := make([][]byte, len(h.runners)) + errs := make([]error, len(h.runners)) + var wg sync.WaitGroup + for i := range h.runners { + wg.Add(1) + go func(idx int) { + defer wg.Done() + sigs[idx], errs[idx] = h.runners[idx].Run(runCtx) + }(i) + } + wg.Wait() + + for i := range h.runners { + member := i + 1 + if errs[i] != nil { + t.Fatalf("member %d run failed: %v", member, errs[i]) + } + if string(sigs[i]) != "fake-bip340-signature" { + t.Fatalf("member %d unexpected signature: %q", member, sigs[i]) + } + state, err := h.coords[i].State(h.handles[i]) + if err != nil { + t.Fatalf("member %d state: %v", member, err) + } + if state != roast.AttemptStateSucceeded { + t.Fatalf("member %d: expected Succeeded, got %v", member, state) + } + } +} + +// The happy path over an oversized group (group size 3, threshold 2): the +// coordinator finalizes over the first t responsive committers and the remaining +// committed member is an observer that aggregates the subset's broadcast shares. +// Which member observes depends on bus timing, but every node obtains the +// signature and reaches Succeeded. The deterministic subset/observer dynamics are +// pinned in TestInteractiveSigningRunner_OversizedAllOnline_FinalizesOverThreshold +// and the offline/observer tests below. +// runMembers runs only the given members' runners concurrently (the rest stay +// offline / non-responsive, never committing) and returns each member's +// signature and error keyed by member index. +func (h harness) runMembers(t *testing.T, members []group.MemberIndex) (map[group.MemberIndex][]byte, map[group.MemberIndex]error) { + t.Helper() + runCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var mu sync.Mutex + sigs := map[group.MemberIndex][]byte{} + errs := map[group.MemberIndex]error{} + var wg sync.WaitGroup + for _, m := range members { + wg.Add(1) + go func(member group.MemberIndex) { + defer wg.Done() + sig, err := h.runners[member-1].Run(runCtx) + mu.Lock() + sigs[member] = sig + errs[member] = err + mu.Unlock() + }(m) + } + wg.Wait() + return sigs, errs +} + +func TestInteractiveSigningRunner_HappyPath(t *testing.T) { + buildInteractiveSigningHarness(t, 3, 2).runAndAssertAllSucceed(t) +} + +// A non-responsive (offline) included member must NOT stall the attempt: the +// coordinator finalizes over the first t responsive committers. Group size 3, +// threshold 2, one NON-COORDINATOR member never runs (never commits), so the two +// responsive members - the coordinator plus one peer - finalize over the t-subset +// and both succeed, and the coordinator's package omits the offline member. +func TestInteractiveSigningRunner_FinalizesOverResponsiveThresholdSubset(t *testing.T) { + h := buildInteractiveSigningHarness(t, 3, 2) + elected := h.runners[0].attempt.ElectedCoordinator() + + var offline group.MemberIndex + for _, m := range h.includedSet { + if m != elected { + offline = m + break + } + } + online := make([]group.MemberIndex, 0, 2) + for _, m := range h.includedSet { + if m != offline { + online = append(online, m) + } + } + + sigs, errs := h.runMembers(t, online) + for _, m := range online { + if errs[m] != nil { + t.Fatalf("online member %d failed: %v", m, errs[m]) + } + if string(sigs[m]) != "fake-bip340-signature" { + t.Fatalf("online member %d unexpected signature: %q", m, sigs[m]) + } + state, err := h.coords[m-1].State(h.handles[m-1]) + if err != nil { + t.Fatalf("member %d state: %v", m, err) + } + if state != roast.AttemptStateSucceeded { + t.Fatalf("member %d: expected Succeeded, got %v", m, state) + } + } + + // The coordinator built the package over exactly the t responsive committers; + // the offline member is absent from the signing subset. + coordCommitments := h.engines[elected-1].newPackageCommitments() + if len(coordCommitments) != 2 { + t.Fatalf("coordinator built package over %d commitments, want t=2", len(coordCommitments)) + } + gotIDs := map[string]struct{}{} + for _, c := range coordCommitments { + gotIDs[c.Identifier] = struct{}{} + } + if _, present := gotIDs[fmt.Sprintf("frost-id-%d", offline)]; present { + t.Fatalf("offline member %d was included in the signing subset: %v", offline, gotIDs) + } + for _, m := range online { + if _, present := gotIDs[fmt.Sprintf("frost-id-%d", m)]; !present { + t.Fatalf("online member %d missing from the signing subset: %v", m, gotIDs) + } + } +} + +// An oversized all-online group (size 3, threshold 2): every member commits, the +// coordinator finalizes over exactly t of them, and the remaining committed +// member observes. All three obtain the signature and reach Succeeded; exactly t +// members sign round 2 and the n-t observers abort their unconsumed round-1 +// nonces. The coordinator's broadcast package names exactly t ascending signers. +func TestInteractiveSigningRunner_OversizedAllOnline_FinalizesOverThreshold(t *testing.T) { + const n, threshold = 3, 2 + h := buildInteractiveSigningHarness(t, n, threshold) + + // A sniffer subscribed before any Run captures the coordinator's broadcast + // package so the test can inspect its signer_ids. + sniffer := h.bus.Subscribe() + elected := h.runners[0].attempt.ElectedCoordinator() + + h.runAndAssertAllSucceed(t) + + totalRound2, totalAbort := 0, 0 + for _, e := range h.engines { + totalRound2 += e.round2CallCount() + totalAbort += e.abortCallCount() + } + if totalRound2 != threshold { + t.Fatalf("expected exactly t=%d round-2 signers, got %d", threshold, totalRound2) + } + // The n-t observers each abort their unconsumed round-1 nonces; signers do not. + if totalAbort != n-threshold { + t.Fatalf("expected %d observer aborts, got %d", n-threshold, totalAbort) + } + + signerIDs := captureCoordinatorSignerIDs(t, sniffer, elected, h.contextHash) + if len(signerIDs) != threshold { + t.Fatalf("package signer_ids = %v, want exactly t=%d", signerIDs, threshold) + } + for i := 1; i < len(signerIDs); i++ { + if signerIDs[i] <= signerIDs[i-1] { + t.Fatalf("signer_ids not strictly ascending: %v", signerIDs) + } + } + includedSetMap := setOf(h.includedSet) + for _, id := range signerIDs { + if _, ok := includedSetMap[id]; !ok { + t.Fatalf("signer id %d not in included set %v", id, h.includedSet) + } + } +} + +// A concluded attempt must leave no retained round-2 collector state, per the +// collector's prune-on-conclusion contract (else a long-lived collector reused +// across attempts accumulates every attempt's envelopes). The collector exposes +// no presence query, but a SURVIVING record makes a re-begin of the same context +// hash under a DIFFERENT binding conflict, whereas a pruned collector accepts +// that fresh begin - so a clean re-begin proves the prune happened. +func TestInteractiveSigningRunner_PrunesCollectorStateAfterSuccess(t *testing.T) { + h := buildInteractiveSigningHarness(t, 3, 2) + h.runAndAssertAllSucceed(t) + + elected := h.runners[0].attempt.ElectedCoordinator() + var differentElected group.MemberIndex + for _, m := range h.includedSet { + if m != elected { + differentElected = m + break + } + } + for i, collector := range h.collectors { + if err := collector.BeginAttempt(h.contextHash[:], differentElected, h.includedSet); err != nil { + t.Fatalf("member %d: collector retained concluded attempt state (conflicting re-begin: %v)", i+1, err) + } + } +} + +// A FAILED attempt must NOT be pruned by the runner: the retained signed +// evidence is what the blame/retry path reads (CoordinatorPackageProofs / +// ClassifyCandidateCulprits). Force aggregation to fail after every node has +// recorded its package and shares, then assert each collector still holds the +// attempt - a conflicting re-begin is rejected, whereas a pruned collector would +// accept it. +func TestInteractiveSigningRunner_PreservesEvidenceOnAggregateFailure(t *testing.T) { + h := buildInteractiveSigningHarness(t, 3, 2) + for _, e := range h.engines { + e.aggregateErr = fmt.Errorf("aggregate share verification failed") + } + + runCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + errs := make([]error, len(h.runners)) + var wg sync.WaitGroup + for i := range h.runners { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, errs[idx] = h.runners[idx].Run(runCtx) + }(i) + } + wg.Wait() + for i, err := range errs { + if err == nil { + t.Fatalf("member %d: expected aggregate failure", i+1) + } + } + + // Retained evidence must survive the failure (not pruned): a surviving record + // makes a re-begin under a DIFFERENT binding conflict. + elected := h.runners[0].attempt.ElectedCoordinator() + var differentElected group.MemberIndex + for _, m := range h.includedSet { + if m != elected { + differentElected = m + break + } + } + for i, collector := range h.collectors { + if err := collector.BeginAttempt(h.contextHash[:], differentElected, h.includedSet); err == nil { + t.Fatalf("member %d: evidence pruned on failure (conflicting re-begin unexpectedly succeeded)", i+1) + } + } +} + +// Adversarial bus traffic - a garbage signing package from a non-elected sender +// and a share from a member outside the included set - must be ignored by the +// runner's sender/membership/attempt filters, leaving the honest run to succeed. +func TestInteractiveSigningRunner_IgnoresAdversarialBusMessages(t *testing.T) { + h := buildInteractiveSigningHarness(t, 3, 2) + + // Determine the elected coordinator, then have a DIFFERENT included member + // spam a garbage package (wrong sender) and an outsider (member 99) spam a + // share. Injected before Run starts, so they sit first in every buffer. + elected := h.runners[0].attempt.ElectedCoordinator() + var nonElected group.MemberIndex + for _, m := range h.includedSet { + if m != elected { + nonElected = m + break + } + } + h.bus.Broadcast(RunnerMessage{ + Type: RunnerMsgSigningPackage, Sender: nonElected, Attempt: h.contextHash, + Payload: []byte("garbage package from a non-coordinator"), + }) + h.bus.Broadcast(RunnerMessage{ + Type: RunnerMsgShareSubmission, Sender: group.MemberIndex(99), Attempt: h.contextHash, + Payload: []byte("garbage share from an outsider"), + }) + + h.runAndAssertAllSucceed(t) +} + +// When Run exits early after the engine session is open (here: ctx expires +// while collecting commitments, before round 2 consumes the nonces), the runner +// must abort the native attempt so the engine drops the resident secret +// nonces/session state rather than leaking it. +func TestInteractiveSigningRunner_AbortsNativeAttemptOnEarlyExit(t *testing.T) { + included := []group.MemberIndex{1, 2} + dkgKey := []byte{0x01, 0x02} + ctx, err := attempt.NewAttemptContext( + "session-1", "key-group-1", dkgKey, + [attempt.MessageDigestLength]byte{0x42}, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt context: %v", err) + } + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + bus := NewInProcessRunnerBus(8) + coord := roast.NewInMemoryCoordinatorWithSigning(1, signer, verifier) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + ara, err := NewActiveRoastAttempt(coord, handle, ctx, "session-1", nil, dkgKey) + if err != nil { + t.Fatalf("active attempt: %v", err) + } + engine := newFakeInteractiveSigningEngine() + engine.coordinatorIdentifier = uint16(ara.ElectedCoordinator()) + runner, err := newInteractiveSigningRunner( + ara, 1, 2, engine, roast.NewRound2Collector(verifier), coord, signer, bus, + ) + if err != nil { + t.Fatalf("runner: %v", err) + } + + // No second node ever broadcasts, so Run blocks waiting for round 1 to reach + // the threshold (as coordinator) or for the coordinator's package (otherwise) + // until the short deadline fires - an early exit with the session already open. + runCtx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if _, err := runner.Run(runCtx); err == nil { + t.Fatal("expected Run to fail on the expired context") + } + if got := engine.abortCallCount(); got != 1 { + t.Fatalf("expected exactly one native abort on early exit, got %d", got) + } +} + +// The runner cross-checks the engine-derived coordinator against the binding's +// own RFC-21 election and fails closed on divergence - BEFORE opening a session - +// so it can never sign an attempt bound to the wrong coordinator. +func TestInteractiveSigningRunner_RejectsEngineCoordinatorMismatch(t *testing.T) { + included := []group.MemberIndex{1, 2} + dkgKey := []byte{0x01, 0x02} + ctx, err := attempt.NewAttemptContext( + "session-1", "key-group-1", dkgKey, + [attempt.MessageDigestLength]byte{0x42}, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt context: %v", err) + } + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + bus := NewInProcessRunnerBus(8) + coord := roast.NewInMemoryCoordinatorWithSigning(1, signer, verifier) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + ara, err := NewActiveRoastAttempt(coord, handle, ctx, "session-1", nil, dkgKey) + if err != nil { + t.Fatalf("active attempt: %v", err) + } + engine := newFakeInteractiveSigningEngine() + // Engine derives a coordinator the binding did NOT elect. + engine.coordinatorIdentifier = uint16(ara.ElectedCoordinator()) + 100 + runner, err := newInteractiveSigningRunner( + ara, 1, 2, engine, roast.NewRound2Collector(verifier), coord, signer, bus, + ) + if err != nil { + t.Fatalf("runner: %v", err) + } + + runCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := runner.Run(runCtx); err == nil { + t.Fatal("expected the engine/binding coordinator mismatch to be rejected") + } + // The mismatch is caught at the derive cross-check, before the session opens. + if engine.openCalls != 0 { + t.Fatalf("expected no session open on coordinator mismatch, got %d", engine.openCalls) + } +} + +// The happy path must key aggregate shares by the engine-derived FROST +// identifiers, not a Go-fabricated placeholder. A full-included attempt +// (threshold == group size) has every member sign, so the aggregate carries +// every member's engine-derived identifier (no observer subsetting to vary it). +func TestInteractiveSigningRunner_UsesEngineDerivedFrostIdentifiers(t *testing.T) { + h := buildInteractiveSigningHarness(t, 2, 2) + h.runAndAssertAllSucceed(t) + + got := map[string]struct{}{} + for _, share := range h.engines[0].lastAggregateShares { + got[share.Identifier] = struct{}{} + } + for _, want := range []string{"frost-id-1", "frost-id-2"} { + if _, ok := got[want]; !ok { + t.Fatalf("aggregate missing engine-derived identifier %q; got %v", want, got) + } + } +} + +func TestSameMemberSet(t *testing.T) { + cases := map[string]struct { + derived []uint16 + included []group.MemberIndex + want bool + }{ + "equal, reordered": {[]uint16{3, 1, 2}, []group.MemberIndex{1, 2, 3}, true}, + "different length": {[]uint16{1, 2}, []group.MemberIndex{1, 2, 3}, false}, + "foreign member": {[]uint16{1, 9}, []group.MemberIndex{1, 2}, false}, + "duplicate masks missing": {[]uint16{1, 1}, []group.MemberIndex{1, 2}, false}, + "empty equal": {[]uint16{}, []group.MemberIndex{}, true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := sameMemberSet(tc.derived, tc.included); got != tc.want { + t.Fatalf("sameMemberSet(%v, %v) = %v, want %v", tc.derived, tc.included, got, tc.want) + } + }) + } +} + +// captureEquivocationEvidence registers a process-wide observer recording every +// emitted equivocation event for the test's duration, returning a snapshot +// accessor. Only one observer may be registered process-wide, so tests using it +// must not run in parallel. +func captureEquivocationEvidence(t *testing.T) func() []roast.EquivocationEvidence { + t.Helper() + var mu sync.Mutex + var captured []roast.EquivocationEvidence + if err := roast.RegisterEquivocationEvidenceObserver(func(ev roast.EquivocationEvidence) { + mu.Lock() + captured = append(captured, ev) + mu.Unlock() + }); err != nil { + t.Fatalf("register equivocation observer: %v", err) + } + t.Cleanup(roast.UnregisterEquivocationEvidenceObserver) + return func() []roast.EquivocationEvidence { + mu.Lock() + defer mu.Unlock() + return append([]roast.EquivocationEvidence(nil), captured...) + } +} + +// craftSigningPackage builds a coordinator-signed signing-package envelope (and +// returns its body hash) over the given FROST package body. +func craftSigningPackage( + t *testing.T, + contextHash [attempt.MessageDigestLength]byte, + elected group.MemberIndex, + body []byte, + signer roast.Signer, +) ([]byte, [32]byte) { + t.Helper() + pkg := &roast.SigningPackage{ + AttemptContextHash: append([]byte(nil), contextHash[:]...), + CoordinatorIDValue: uint32(elected), + SigningPackageBytes: append([]byte(nil), body...), + } + payload, err := pkg.SignableBytes() + if err != nil { + t.Fatalf("signing package signable bytes: %v", err) + } + sig, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign signing package: %v", err) + } + pkg.CoordinatorSignature = sig + envelope, err := pkg.Marshal() + if err != nil { + t.Fatalf("marshal signing package: %v", err) + } + bodyHash, err := pkg.BodyHash() + if err != nil { + t.Fatalf("signing package body hash: %v", err) + } + return envelope, bodyHash +} + +// craftShareSubmission builds a submitter-signed accepted-share envelope for a +// member, binding the elected coordinator and authoritative package body hash so +// the collector accepts it (a body-different share for the same member is then +// member equivocation). +func craftShareSubmission( + t *testing.T, + contextHash [attempt.MessageDigestLength]byte, + member, elected group.MemberIndex, + pkgBodyHash [32]byte, + share []byte, + signer roast.Signer, +) []byte { + t.Helper() + sub := &roast.ShareSubmission{ + AttemptContextHash: append([]byte(nil), contextHash[:]...), + SubmitterIDValue: uint32(member), + CoordinatorIDValue: uint32(elected), + SigningPackageHash: append([]byte(nil), pkgBodyHash[:]...), + SignatureShare: append([]byte(nil), share...), + } + payload, err := sub.SignableBytes() + if err != nil { + t.Fatalf("share submission signable bytes: %v", err) + } + sig, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign share submission: %v", err) + } + sub.SubmitterSignature = sig + envelope, err := sub.Marshal() + if err != nil { + t.Fatalf("marshal share submission: %v", err) + } + return envelope +} + +// buildEquivocationRunner builds a single runner with a fresh collector for the +// evidence-retention tests, returning the runner, its collector, the attempt +// context hash, and the elected coordinator. +func buildEquivocationRunner(t *testing.T, included []group.MemberIndex) ( + *interactiveSigningRunner, *roast.Round2Collector, [attempt.MessageDigestLength]byte, group.MemberIndex, +) { + t.Helper() + dkgKey := []byte{0x01, 0x02} + ctx, err := attempt.NewAttemptContext( + "session-1", "key-group-1", dkgKey, + [attempt.MessageDigestLength]byte{0x42}, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt context: %v", err) + } + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + bus := NewInProcessRunnerBus(16) + coord := roast.NewInMemoryCoordinatorWithSigning(included[0], signer, verifier) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + ara, err := NewActiveRoastAttempt(coord, handle, ctx, "session-1", nil, dkgKey) + if err != nil { + t.Fatalf("active attempt: %v", err) + } + collector := roast.NewRound2Collector(verifier) + runner, err := newInteractiveSigningRunner( + ara, included[0], 2, newFakeInteractiveSigningEngine(), collector, coord, signer, bus, + ) + if err != nil { + t.Fatalf("runner: %v", err) + } + return runner, collector, ctx.Hash(), ara.ElectedCoordinator() +} + +// A second, body-different package from the elected coordinator must be recorded +// as coordinator equivocation, not dropped because obtainSigningPackage already +// returned on the first one. +func TestInteractiveSigningRunner_RetainsCoordinatorPackageEquivocation(t *testing.T) { + included := []group.MemberIndex{1, 2} + runner, collector, contextHash, elected := buildEquivocationRunner(t, included) + signer := fixedTestSigner{} + + if err := collector.BeginAttempt(contextHash[:], elected, included); err != nil { + t.Fatalf("collector begin: %v", err) + } + // The authoritative package is recorded first (as Run does). + authEnvelope, _ := craftSigningPackage(t, contextHash, elected, []byte("authoritative-package"), signer) + authPkg := &roast.SigningPackage{} + if err := authPkg.Unmarshal(authEnvelope); err != nil { + t.Fatalf("unmarshal authoritative: %v", err) + } + if err := collector.RecordSigningPackage(authPkg); err != nil { + t.Fatalf("record authoritative: %v", err) + } + + // A body-different package the coordinator also broadcast sits buffered. + conflictEnvelope, _ := craftSigningPackage(t, contextHash, elected, []byte("equivocating-package"), signer) + stream := make(chan RunnerMessage, 4) + stream <- RunnerMessage{Type: RunnerMsgSigningPackage, Sender: elected, Attempt: contextHash, Payload: conflictEnvelope} + + evidence := captureEquivocationEvidence(t) + runner.recordBufferedCoordinatorPackages(stream, elected, contextHash) + + got := evidence() + if len(got) != 1 || got[0].Kind != roast.EquivocationKindSigningPackageConflict || got[0].Sender != elected { + t.Fatalf("expected one signing-package conflict from member %d, got %+v", elected, got) + } +} + +// A member that double-signs (a body-different second accepted share) must be +// recorded as equivocation even after its first share was already counted - +// collectShares must not drop the later envelope before the collector sees it. +func TestInteractiveSigningRunner_RetainsMemberShareEquivocation(t *testing.T) { + included := []group.MemberIndex{1, 2} + runner, collector, contextHash, elected := buildEquivocationRunner(t, included) + signer := fixedTestSigner{} + + if err := collector.BeginAttempt(contextHash[:], elected, included); err != nil { + t.Fatalf("collector begin: %v", err) + } + authEnvelope, pkgBodyHash := craftSigningPackage(t, contextHash, elected, []byte("authoritative-package"), signer) + authPkg := &roast.SigningPackage{} + if err := authPkg.Unmarshal(authEnvelope); err != nil { + t.Fatalf("unmarshal authoritative: %v", err) + } + if err := collector.RecordSigningPackage(authPkg); err != nil { + t.Fatalf("record authoritative: %v", err) + } + + // Member 1 double-signs; member 2 sends one share. Ordered so member 1's + // first share is counted before its conflicting second arrives. + share1a := craftShareSubmission(t, contextHash, 1, elected, pkgBodyHash, []byte("share-1-a"), signer) + share1b := craftShareSubmission(t, contextHash, 1, elected, pkgBodyHash, []byte("share-1-b"), signer) + share2 := craftShareSubmission(t, contextHash, 2, elected, pkgBodyHash, []byte("share-2"), signer) + stream := make(chan RunnerMessage, 8) + stream <- RunnerMessage{Type: RunnerMsgShareSubmission, Sender: 1, Attempt: contextHash, Payload: share1a} + stream <- RunnerMessage{Type: RunnerMsgShareSubmission, Sender: 1, Attempt: contextHash, Payload: share1b} + stream <- RunnerMessage{Type: RunnerMsgShareSubmission, Sender: 2, Attempt: contextHash, Payload: share2} + + evidence := captureEquivocationEvidence(t) + into := map[group.MemberIndex][]byte{} + if err := runner.collectShares(context.Background(), stream, contextHash, included, into); err != nil { + t.Fatalf("collect shares: %v", err) + } + + // The first accepted share per member is counted; the double-sign is retained. + if string(into[1]) != "share-1-a" || string(into[2]) != "share-2" { + t.Fatalf("unexpected counted shares: 1=%q 2=%q", into[1], into[2]) + } + got := evidence() + if len(got) != 1 || got[0].Kind != roast.EquivocationKindShareConflict || got[0].Sender != 1 { + t.Fatalf("expected one share conflict from member 1, got %+v", got) + } +} + +// A body-different duplicate queued BEHIND the share that fills the final slot +// must still be retained: collectShares stops counting once `into` is full, so +// it must drain the remaining buffered shares before Run aggregates and prunes. +func TestInteractiveSigningRunner_RetainsQueuedShareEquivocationAfterCollection(t *testing.T) { + included := []group.MemberIndex{1, 2} + runner, collector, contextHash, elected := buildEquivocationRunner(t, included) + signer := fixedTestSigner{} + + if err := collector.BeginAttempt(contextHash[:], elected, included); err != nil { + t.Fatalf("collector begin: %v", err) + } + authEnvelope, pkgBodyHash := craftSigningPackage(t, contextHash, elected, []byte("authoritative-package"), signer) + authPkg := &roast.SigningPackage{} + if err := authPkg.Unmarshal(authEnvelope); err != nil { + t.Fatalf("unmarshal authoritative: %v", err) + } + if err := collector.RecordSigningPackage(authPkg); err != nil { + t.Fatalf("record authoritative: %v", err) + } + + // Member 2's first share fills the final slot; its body-different duplicate is + // queued right behind it, so the collection loop exits before reading it and + // only the post-completion drain can retain it. + share1 := craftShareSubmission(t, contextHash, 1, elected, pkgBodyHash, []byte("share-1"), signer) + share2a := craftShareSubmission(t, contextHash, 2, elected, pkgBodyHash, []byte("share-2-a"), signer) + share2b := craftShareSubmission(t, contextHash, 2, elected, pkgBodyHash, []byte("share-2-b"), signer) + stream := make(chan RunnerMessage, 8) + stream <- RunnerMessage{Type: RunnerMsgShareSubmission, Sender: 1, Attempt: contextHash, Payload: share1} + stream <- RunnerMessage{Type: RunnerMsgShareSubmission, Sender: 2, Attempt: contextHash, Payload: share2a} + stream <- RunnerMessage{Type: RunnerMsgShareSubmission, Sender: 2, Attempt: contextHash, Payload: share2b} + + evidence := captureEquivocationEvidence(t) + into := map[group.MemberIndex][]byte{} + if err := runner.collectShares(context.Background(), stream, contextHash, included, into); err != nil { + t.Fatalf("collect shares: %v", err) + } + + if string(into[1]) != "share-1" || string(into[2]) != "share-2-a" { + t.Fatalf("unexpected counted shares: 1=%q 2=%q", into[1], into[2]) + } + got := evidence() + if len(got) != 1 || got[0].Kind != roast.EquivocationKindShareConflict || got[0].Sender != 2 { + t.Fatalf("expected one queued share conflict from member 2, got %+v", got) + } +} + +// The post-completion drain must be bounded: a peer that keeps the share stream +// non-empty (flooding body-different shares) must not livelock collectShares +// once enough valid shares are already collected. With `into` pre-filled only +// the drain runs, and it must return promptly despite the continuous flood. +func TestInteractiveSigningRunner_DrainDoesNotLivelockUnderShareFlood(t *testing.T) { + included := []group.MemberIndex{1, 2} + runner, collector, contextHash, elected := buildEquivocationRunner(t, included) + signer := fixedTestSigner{} + if err := collector.BeginAttempt(contextHash[:], elected, included); err != nil { + t.Fatalf("collector begin: %v", err) + } + authEnvelope, pkgBodyHash := craftSigningPackage(t, contextHash, elected, []byte("authoritative-package"), signer) + authPkg := &roast.SigningPackage{} + if err := authPkg.Unmarshal(authEnvelope); err != nil { + t.Fatalf("unmarshal authoritative: %v", err) + } + if err := collector.RecordSigningPackage(authPkg); err != nil { + t.Fatalf("record authoritative: %v", err) + } + + floodEnvelope := craftShareSubmission(t, contextHash, 2, elected, pkgBodyHash, []byte("flood"), signer) + floodMsg := RunnerMessage{Type: RunnerMsgShareSubmission, Sender: 2, Attempt: contextHash, Payload: floodEnvelope} + stream := make(chan RunnerMessage, 8) + for i := 0; i < cap(stream); i++ { + stream <- floodMsg // full at entry, so the bound is actually exercised + } + stop := make(chan struct{}) + floodDone := make(chan struct{}) + go func() { + defer close(floodDone) + for { + select { + case <-stop: + return + case stream <- floodMsg: // keep the stream non-empty as the drain reads + } + } + }() + + // `into` already complete -> the collection loop is skipped; only the drain runs. + into := map[group.MemberIndex][]byte{1: []byte("a"), 2: []byte("b")} + returned := make(chan struct{}) + go func() { + defer close(returned) + _ = runner.collectShares(context.Background(), stream, contextHash, included, into) + }() + select { + case <-returned: + // Bounded drain returned despite the flood. + case <-time.After(2 * time.Second): + t.Fatal("collectShares drain livelocked under share flood") + } + close(stop) + <-floodDone +} + +// beginSyntheticAttempt makes a second attempt live in the SAME collector, keyed +// by a synthetic context hash with the given elected coordinator, and records an +// authoritative package for it - so a cross-attempt test can prove a payload +// signed for THIS attempt would be accepted here absent the signed-hash guard. +// Returns that attempt's authoritative package body hash. +func beginSyntheticAttempt( + t *testing.T, + collector *roast.Round2Collector, + hash [attempt.MessageDigestLength]byte, + elected group.MemberIndex, + included []group.MemberIndex, + signer roast.Signer, +) [32]byte { + t.Helper() + if err := collector.BeginAttempt(hash[:], elected, included); err != nil { + t.Fatalf("begin synthetic attempt: %v", err) + } + envelope, bodyHash := craftSigningPackage(t, hash, elected, []byte("authoritative-B"), signer) + pkg := &roast.SigningPackage{} + if err := pkg.Unmarshal(envelope); err != nil { + t.Fatalf("unmarshal synthetic authoritative: %v", err) + } + if err := collector.RecordSigningPackage(pkg); err != nil { + t.Fatalf("record synthetic authoritative: %v", err) + } + return bodyHash +} + +func recordAuthoritativePackage( + t *testing.T, + collector *roast.Round2Collector, + hash [attempt.MessageDigestLength]byte, + elected group.MemberIndex, + signer roast.Signer, +) { + t.Helper() + envelope, _ := craftSigningPackage(t, hash, elected, []byte("authoritative-A"), signer) + pkg := &roast.SigningPackage{} + if err := pkg.Unmarshal(envelope); err != nil { + t.Fatalf("unmarshal authoritative: %v", err) + } + if err := collector.RecordSigningPackage(pkg); err != nil { + t.Fatalf("record authoritative: %v", err) + } +} + +// A share carried in a current-attempt (A) bus message but SIGNED for another +// live attempt (B) must not be counted toward A - the runner must check the +// signed AttemptContextHash, not the unsigned outer bus field. Without the +// guard, the collector records it under B (accepted) and returns nil, so this +// code would count it toward A. +func TestInteractiveSigningRunner_RejectsCrossAttemptShare(t *testing.T) { + included := []group.MemberIndex{1, 2} + runner, collector, hashA, electedA := buildEquivocationRunner(t, included) + signer := fixedTestSigner{} + if err := collector.BeginAttempt(hashA[:], electedA, included); err != nil { + t.Fatalf("collector begin A: %v", err) + } + recordAuthoritativePackage(t, collector, hashA, electedA, signer) + + // Attempt B live in the same collector (electedB == electedA so an electedA- + // bound payload is acceptable there). + hashB := [attempt.MessageDigestLength]byte{0x99} + pkgBodyHashB := beginSyntheticAttempt(t, collector, hashB, electedA, included, signer) + + shareForB := craftShareSubmission(t, hashB, 1, electedA, pkgBodyHashB, []byte("share-for-B"), signer) + msg := RunnerMessage{Type: RunnerMsgShareSubmission, Sender: 1, Attempt: hashA, Payload: shareForB} + + into := map[group.MemberIndex][]byte{} + runner.recordShareMessage(msg, hashA, setOf(included), into) + if _, counted := into[1]; counted { + t.Fatal("share signed for attempt B was counted toward attempt A") + } +} + +// A package carried in a current-attempt (A) bus message but SIGNED for another +// live attempt (B) must not be recorded by A's buffered-package drain. Without +// the guard the drain records it under B (a body-different conflict there), +// emitting a B equivocation event A had no business producing. +func TestInteractiveSigningRunner_RejectsCrossAttemptPackageInDrain(t *testing.T) { + included := []group.MemberIndex{1, 2} + runner, collector, hashA, electedA := buildEquivocationRunner(t, included) + signer := fixedTestSigner{} + if err := collector.BeginAttempt(hashA[:], electedA, included); err != nil { + t.Fatalf("collector begin A: %v", err) + } + recordAuthoritativePackage(t, collector, hashA, electedA, signer) + + hashB := [attempt.MessageDigestLength]byte{0x99} + _ = beginSyntheticAttempt(t, collector, hashB, electedA, included, signer) + + // A body-different package signed for B, rewrapped as a current-attempt (A) + // message from electedA. + crossEnvelope, _ := craftSigningPackage(t, hashB, electedA, []byte("equivocating-B"), signer) + stream := make(chan RunnerMessage, 4) + stream <- RunnerMessage{Type: RunnerMsgSigningPackage, Sender: electedA, Attempt: hashA, Payload: crossEnvelope} + + evidence := captureEquivocationEvidence(t) + runner.recordBufferedCoordinatorPackages(stream, electedA, hashA) + if got := evidence(); len(got) != 0 { + t.Fatalf("package signed for attempt B was recorded under it via A's drain: %+v", got) + } +} + +func TestNewInteractiveSigningRunner_RejectsInvalidConstruction(t *testing.T) { + // A valid baseline to vary one field at a time. + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x01, 0x02} + ctx, err := attempt.NewAttemptContext( + "session-1", "key-group-1", dkgKey, + [attempt.MessageDigestLength]byte{0x42}, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt context: %v", err) + } + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + bus := NewInProcessRunnerBus(8) + coord := roast.NewInMemoryCoordinatorWithSigning(1, signer, verifier) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + ara, err := NewActiveRoastAttempt(coord, handle, ctx, "session-1", nil, dkgKey) + if err != nil { + t.Fatalf("active attempt: %v", err) + } + engine := newFakeInteractiveSigningEngine() + collector := roast.NewRound2Collector(verifier) + + // Sanity: the baseline constructs. + if _, err := newInteractiveSigningRunner(ara, 1, 2, engine, collector, coord, signer, bus); err != nil { + t.Fatalf("baseline construction failed: %v", err) + } + + tests := map[string]func() (*interactiveSigningRunner, error){ + "nil attempt": func() (*interactiveSigningRunner, error) { + return newInteractiveSigningRunner(nil, 1, 2, engine, collector, coord, signer, bus) + }, + "nil engine": func() (*interactiveSigningRunner, error) { + return newInteractiveSigningRunner(ara, 1, 2, nil, collector, coord, signer, bus) + }, + "nil collector": func() (*interactiveSigningRunner, error) { + return newInteractiveSigningRunner(ara, 1, 2, engine, nil, coord, signer, bus) + }, + "nil coordinator": func() (*interactiveSigningRunner, error) { + return newInteractiveSigningRunner(ara, 1, 2, engine, collector, nil, signer, bus) + }, + "nil signer": func() (*interactiveSigningRunner, error) { + return newInteractiveSigningRunner(ara, 1, 2, engine, collector, coord, nil, bus) + }, + "nil bus": func() (*interactiveSigningRunner, error) { + return newInteractiveSigningRunner(ara, 1, 2, engine, collector, coord, signer, nil) + }, + "zero threshold": func() (*interactiveSigningRunner, error) { + return newInteractiveSigningRunner(ara, 1, 0, engine, collector, coord, signer, bus) + }, + "member not included": func() (*interactiveSigningRunner, error) { + return newInteractiveSigningRunner(ara, 99, 2, engine, collector, coord, signer, bus) + }, + "threshold exceeds included set": func() (*interactiveSigningRunner, error) { + // included set has 3 members; a threshold of 4 can never form a subset. + return newInteractiveSigningRunner(ara, 1, 4, engine, collector, coord, signer, bus) + }, + } + for name, build := range tests { + t.Run(name, func(t *testing.T) { + if _, err := build(); err == nil { + t.Fatal("expected invalid construction to be rejected") + } + }) + } +} + +// An OBSERVER - a member that committed in round 1 but is NOT in the package's +// signer set - must aggregate the subset's broadcast shares to obtain the +// signature, mark its attempt Succeeded, and return the signature WITHOUT running +// round 2; and even on success it must abort the engine session to drop its +// unconsumed round-1 nonces (success otherwise suppresses the abort, leaking +// them). Driven deterministically: the coordinator's signed package (signer_ids +// excluding this member) and the signers' shares are pre-injected on the bus. +func TestInteractiveSigningRunner_ObserverAggregatesAndAbortsWithoutSigning(t *testing.T) { + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x01, 0x02} + ctx, err := attempt.NewAttemptContext( + "session-1", "key-group-1", dkgKey, + [attempt.MessageDigestLength]byte{0x42}, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt context: %v", err) + } + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + bus := NewInProcessRunnerBus(16) + contextHash := ctx.Hash() + + // The election is deterministic for the attempt; probe it so the observer is a + // NON-coordinator (a coordinator is always a signer) and the signer subset is + // the other two members. + probe := roast.NewInMemoryCoordinatorWithSigning(1, signer, verifier) + probeHandle, err := probe.BeginAttempt(ctx) + if err != nil { + t.Fatalf("probe begin: %v", err) + } + probeAttempt, err := NewActiveRoastAttempt(probe, probeHandle, ctx, "session-1", nil, dkgKey) + if err != nil { + t.Fatalf("probe active attempt: %v", err) + } + elected := probeAttempt.ElectedCoordinator() + + var observer group.MemberIndex + for _, m := range included { + if m != elected { + observer = m + break + } + } + // included is ascending, so iterating it (skipping the observer) yields an + // ascending signer subset - what signer_ids requires. + signers := make([]group.MemberIndex, 0, 2) + for _, m := range included { + if m != observer { + signers = append(signers, m) + } + } + + coord := roast.NewInMemoryCoordinatorWithSigning(observer, signer, verifier) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + ara, err := NewActiveRoastAttempt(coord, handle, ctx, "session-1", nil, dkgKey) + if err != nil { + t.Fatalf("active attempt: %v", err) + } + engine := newFakeInteractiveSigningEngine() + engine.coordinatorIdentifier = uint16(ara.ElectedCoordinator()) + runner, err := newInteractiveSigningRunner( + ara, observer, 2, engine, roast.NewRound2Collector(verifier), coord, signer, bus, + ) + if err != nil { + t.Fatalf("runner: %v", err) + } + + // Pre-inject the coordinator's signed package (signer_ids = the two signers, + // excluding the observer) plus both signers' shares, so the observer's await + // and share collection complete from the bus. + pkgEnvelope, pkgBodyHash := craftSigningPackageWithSigners( + t, contextHash, elected, []byte("fake-signing-package"), signers, signer, + ) + bus.Broadcast(RunnerMessage{Type: RunnerMsgSigningPackage, Sender: elected, Attempt: contextHash, Payload: pkgEnvelope}) + for _, s := range signers { + shareEnvelope := craftShareSubmission( + t, contextHash, s, elected, pkgBodyHash, []byte(fmt.Sprintf("share-%d", s)), signer, + ) + bus.Broadcast(RunnerMessage{Type: RunnerMsgShareSubmission, Sender: s, Attempt: contextHash, Payload: shareEnvelope}) + } + + runCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + sig, err := runner.Run(runCtx) + if err != nil { + t.Fatalf("observer run failed: %v", err) + } + if string(sig) != "fake-bip340-signature" { + t.Fatalf("observer unexpected signature: %q", sig) + } + if engine.round2CallCount() != 0 { + t.Fatalf("observer ran round 2 %d times, want 0 (it is not a signer)", engine.round2CallCount()) + } + if engine.abortCallCount() != 1 { + t.Fatalf("observer aborted %d times, want exactly 1 (drop unconsumed round-1 nonces on success)", engine.abortCallCount()) + } + state, err := coord.State(handle) + if err != nil { + t.Fatalf("observer state: %v", err) + } + if state != roast.AttemptStateSucceeded { + t.Fatalf("observer expected Succeeded, got %v", state) + } +} + +// An included NON-SIGNER (observer) that broadcasts a DIVERGENT share - a +// targeted coordinator-equivocation victim that signed a different package - must +// still be RETAINED by the collector as EquivocationKindDivergentShare evidence +// for the f+1 blame/transition path, even though its share does not count toward +// the aggregate (it is not in the signing subset). Retention is gated by +// included-set membership, not the signer set. +func TestInteractiveSigningRunner_RetainsObserverDivergentShareAsEvidence(t *testing.T) { + included := []group.MemberIndex{1, 2, 3} + runner, collector, contextHash, elected := buildEquivocationRunner(t, included) + signer := fixedTestSigner{} + if err := collector.BeginAttempt(contextHash[:], elected, included); err != nil { + t.Fatalf("collector begin: %v", err) + } + // Authoritative package over the signing subset {1,2}; its body hash binds + // accepted shares. + authEnvelope, _ := craftSigningPackage(t, contextHash, elected, []byte("authoritative-package"), signer) + authPkg := &roast.SigningPackage{} + if err := authPkg.Unmarshal(authEnvelope); err != nil { + t.Fatalf("unmarshal authoritative: %v", err) + } + if err := collector.RecordSigningPackage(authPkg); err != nil { + t.Fatalf("record authoritative: %v", err) + } + + // Member 3 is an included observer (not in the {1,2} signing subset) but was + // handed a DIFFERENT package, so it broadcasts a share bound to that other + // package's body hash - divergent vs the authoritative package. + _, otherBodyHash := craftSigningPackage(t, contextHash, elected, []byte("equivocating-package-for-3"), signer) + divergentShare := craftShareSubmission(t, contextHash, 3, elected, otherBodyHash, []byte("share-3-divergent"), signer) + msg := RunnerMessage{Type: RunnerMsgShareSubmission, Sender: 3, Attempt: contextHash, Payload: divergentShare} + + evidence := captureEquivocationEvidence(t) + into := map[group.MemberIndex][]byte{} + // Signer set excludes member 3 (the observer); counting must skip it while + // retention must not. + runner.recordShareMessage(msg, contextHash, setOf([]group.MemberIndex{1, 2}), into) + + if _, counted := into[3]; counted { + t.Fatal("observer (non-signer) share was counted toward the aggregate") + } + got := evidence() + if len(got) != 1 || got[0].Kind != roast.EquivocationKindDivergentShare || got[0].Sender != 3 { + t.Fatalf("expected one divergent-share evidence retained from member 3, got %+v", got) + } +} + +// captureCoordinatorSignerIDs drains a sniffer subscriber's signing-package +// stream for the elected coordinator's package for the attempt and returns its +// signer_ids. It fails the test if no such package was observed. +func captureCoordinatorSignerIDs( + t *testing.T, + sniffer *RunnerBusSubscriber, + elected group.MemberIndex, + contextHash [attempt.MessageDigestLength]byte, +) []group.MemberIndex { + t.Helper() + for { + select { + case msg := <-sniffer.SigningPackages(): + if msg.Sender != elected || msg.Attempt != contextHash { + continue + } + pkg := &roast.SigningPackage{} + if err := pkg.Unmarshal(msg.Payload); err != nil { + t.Fatalf("unmarshal coordinator package: %v", err) + } + return pkg.SignerIDs() + default: + t.Fatal("coordinator package not observed on the bus") + return nil + } + } +} + +// craftSigningPackageWithSigners builds a coordinator-signed package carrying an +// explicit signer subset (signer_ids, which must be ascending/distinct), and +// returns its envelope and body hash. +func craftSigningPackageWithSigners( + t *testing.T, + contextHash [attempt.MessageDigestLength]byte, + elected group.MemberIndex, + body []byte, + signers []group.MemberIndex, + signer roast.Signer, +) ([]byte, [32]byte) { + t.Helper() + signerIDs := make([]uint32, 0, len(signers)) + for _, m := range signers { + signerIDs = append(signerIDs, uint32(m)) + } + pkg := &roast.SigningPackage{ + AttemptContextHash: append([]byte(nil), contextHash[:]...), + CoordinatorIDValue: uint32(elected), + SigningPackageBytes: append([]byte(nil), body...), + SignerIDsValue: signerIDs, + } + payload, err := pkg.SignableBytes() + if err != nil { + t.Fatalf("signing package signable bytes: %v", err) + } + sig, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign signing package: %v", err) + } + pkg.CoordinatorSignature = sig + envelope, err := pkg.Marshal() + if err != nil { + t.Fatalf("marshal signing package: %v", err) + } + bodyHash, err := pkg.BodyHash() + if err != nil { + t.Fatalf("signing package body hash: %v", err) + } + return envelope, bodyHash +} diff --git a/pkg/frost/signing/roast_runner_real_cgo_coordinator_equivocation_frost_native_test.go b/pkg/frost/signing/roast_runner_real_cgo_coordinator_equivocation_frost_native_test.go new file mode 100644 index 0000000000..ffd81a1aec --- /dev/null +++ b/pkg/frost/signing/roast_runner_real_cgo_coordinator_equivocation_frost_native_test.go @@ -0,0 +1,291 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "bytes" + "errors" + "fmt" + "sort" + "testing" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This test closes the fourth real-crypto-under-failure gap: a COORDINATOR +// EQUIVOCATION - the elected coordinator distributing two VALID, coordinator- +// signed signing packages with the same attempt context but different bytes - +// bypasses the f+1 accuser gate and forces INSTANT PERMANENT exclusion of the +// coordinator (verifiedCoordinatorEquivocations, next_attempt.go). Two authentic +// bodies signed by the coordinator's own operator key are unforgeable proof, so +// a single honest observer's evidence suffices. +// +// The "real crypto" that matters here is the Go-side operator-key ECDSA +// signature over each package body (pkg/chain), NOT FROST: the equivocation +// adjudication is a pure Go-side operation, so no FROST signing rounds run. The +// existing policy-level test (next_attempt_coordinator_equivocation_test.go) +// exercises this with a SHA-256 fakeVerifier; this test uses a REAL secp256k1 +// operator-key Signer/Verifier, so the two packages must genuinely authenticate +// under the same verifier the coordinator instance carries. A non-vacuous +// negative control (a package with a corrupted signature is rejected by the real +// verifier and does NOT trigger exclusion) proves the verifier is genuinely +// cryptographic and the positive exclusion is caused by two AUTHENTIC distinct +// bodies, not a stub that accepts anything. +// +// A real DKG is run (as in the sibling real-cgo tests) only to produce an +// authentic key group / seed; it is not load-bearing for the equivocation logic. + +// operatorKeyRoastSigner is a real roast.Signer backed by one member's +// secp256k1 operator private key (via local_v1's chain.Signing). Unlike the +// fixedTestSigner used elsewhere, its signatures actually verify. +type operatorKeyRoastSigner struct{ signing chain.Signing } + +func (s operatorKeyRoastSigner) Sign(payload []byte) ([]byte, error) { + return s.signing.Sign(payload) +} + +// operatorKeyRoastVerifier is a real roast.SignatureVerifier: it verifies a +// signature attributed to a member against that member's uncompressed operator +// public key. Any local_v1 signer serves as the verification engine because +// VerifyWithPublicKey verifies against the SUPPLIED public key. +type operatorKeyRoastVerifier struct { + signing chain.Signing + publicKeys map[group.MemberIndex][]byte // uncompressed operator pubkey bytes +} + +func (v operatorKeyRoastVerifier) Verify(payload, signature []byte, signer group.MemberIndex) error { + pub, ok := v.publicKeys[signer] + if !ok { + return fmt.Errorf("%w: no operator public key for member %d", roast.ErrSignatureInvalid, signer) + } + valid, err := v.signing.VerifyWithPublicKey(payload, signature, pub) + if err != nil { + return fmt.Errorf("%w: member %d: %s", roast.ErrSignatureInvalid, signer, err.Error()) + } + if !valid { + return fmt.Errorf("%w: member %d", roast.ErrSignatureInvalid, signer) + } + return nil +} + +func TestRealCgoInteractiveSigning_CoordinatorEquivocationForcesInstantPermanentExclusion(t *testing.T) { + setupRealCgoSignerState(t) + + engine := &buildTaggedTBTCSignerEngine{} + sessionID := fmt.Sprintf("real-cgo-coord-equivocation-%d", realCgoSessionSeq.Add(1)) + + const n = 3 + const threshold uint16 = 2 + participantIDs := []byte{1, 2, 3} + included := []group.MemberIndex{1, 2, 3} + + // Authentic key group + seed (family consistency; not load-bearing here). + keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold) + keyGroupSeed := []byte(keyGroup) + + var messageDigest [attempt.MessageDigestLength]byte + for i := range messageDigest { + messageDigest[i] = 0x42 + } + attempt1Ctx, err := attempt.NewAttemptContext( + sessionID, keyGroup, keyGroupSeed, messageDigest, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt 1 context: %v", err) + } + + // Real per-member operator keys + a real Signer/Verifier over all members. + privKeys := make(map[group.MemberIndex]*operator.PrivateKey, n) + pubKeyBytes := make(map[group.MemberIndex][]byte, n) + for _, m := range included { + priv, pub, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (seat %d): %v", m, err) + } + privKeys[m] = priv + pubKeyBytes[m] = operator.MarshalUncompressed(pub) + } + verifier := operatorKeyRoastVerifier{ + signing: local_v1.NewSigner(privKeys[included[0]]), // engine only; verifies against supplied pubkey + publicKeys: pubKeyBytes, + } + + // Resolve attempt 1's deterministic elected coordinator via a probe binding. + probeSigner := operatorKeyRoastSigner{signing: local_v1.NewSigner(privKeys[included[0]])} + probeCoord := roast.NewInMemoryCoordinatorWithSigning(included[0], probeSigner, verifier) + probeHandle, err := probeCoord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("probe begin attempt: %v", err) + } + probeBinding, err := NewActiveRoastAttempt(probeCoord, probeHandle, attempt1Ctx, sessionID, nil, keyGroupSeed) + if err != nil { + t.Fatalf("probe binding: %v", err) + } + coordinator := probeBinding.ElectedCoordinator() + + nonCoord := make([]group.MemberIndex, 0, n-1) + for _, m := range included { + if m != coordinator { + nonCoord = append(nonCoord, m) + } + } + sort.Slice(nonCoord, func(i, j int) bool { return nonCoord[i] < nonCoord[j] }) + t.Logf("coordinator(equivocator)=%d observers=%v", coordinator, nonCoord) + + // The REAL coordinator package signer uses the coordinator's own operator key. + coordSigner := operatorKeyRoastSigner{signing: local_v1.NewSigner(privKeys[coordinator])} + + prevHash := attempt1Ctx.Hash() + newSignedPkg := func(root []byte) *roast.SigningPackage { + p := &roast.SigningPackage{ + AttemptContextHash: append([]byte(nil), prevHash[:]...), + CoordinatorIDValue: uint32(coordinator), + SigningPackageBytes: []byte("frost-signing-package-bytes"), + TaprootMerkleRoot: root, + } + if err := roast.SignSigningPackage(coordSigner, p); err != nil { + t.Fatalf("sign package: %v", err) + } + return p + } + // Two body-DISTINCT packages, SAME attempt context, SAME coordinator. The + // bodies differ via the taproot root (0 vs 32 bytes) - signing the same body + // twice would give a different ECDSA signature but the SAME BodyHash, which is + // NOT an equivocation. + pkgA := newSignedPkg(nil) // key-path spend + pkgB := newSignedPkg(bytes.Repeat([]byte{0xab}, roast.TaprootMerkleRootLength)) // script-path spend + + // ---- FAULT REACHED: a genuine equivocation, not a synthetic pass. ---- + if err := roast.AuthenticateSigningPackage(verifier, pkgA, coordinator, prevHash[:]); err != nil { + t.Fatalf("pkgA must authenticate under the real verifier: %v", err) + } + if err := roast.AuthenticateSigningPackage(verifier, pkgB, coordinator, prevHash[:]); err != nil { + t.Fatalf("pkgB must authenticate under the real verifier: %v", err) + } + if pkgA.CoordinatorID() != coordinator || pkgB.CoordinatorID() != coordinator { + t.Fatalf("both packages must name the elected coordinator %d", coordinator) + } + bhA, err := pkgA.BodyHash() + if err != nil { + t.Fatalf("pkgA body hash: %v", err) + } + bhB, err := pkgB.BodyHash() + if err != nil { + t.Fatalf("pkgB body hash: %v", err) + } + if bhA == bhB { + t.Fatalf("packages must be byte-distinct to be an equivocation; both hashed to %x", bhA) + } + if !bytes.Equal(pkgA.AttemptContextHash, pkgB.AttemptContextHash) || + !bytes.Equal(pkgA.AttemptContextHash, prevHash[:]) { + t.Fatalf("both packages must bind the same live attempt context") + } + if len(pkgA.CoordinatorSignature) == 0 || len(pkgB.CoordinatorSignature) == 0 { + t.Fatalf("both packages must carry a real coordinator signature") + } + + proofA, err := pkgA.Marshal() + if err != nil { + t.Fatalf("marshal pkgA: %v", err) + } + proofB, err := pkgB.Marshal() + if err != nil { + t.Fatalf("marshal pkgB: %v", err) + } + + // Split the two proofs across two honest observers (the targeted case: no + // single observer saw both). All live members are senders so nobody is + // silence-parked; excluding the coordinator leaves threshold=2 members + // (feasible for n=3, t=2). Every snapshot carries NO reject/conflict/overflow + // evidence, so the ONLY mechanism that can exclude the coordinator is the + // equivocation path (not an f+1 tally). + snaps := []roast.LocalEvidenceSnapshot{ + {SenderIDValue: uint32(coordinator), AttemptContextHash: append([]byte(nil), prevHash[:]...)}, + {SenderIDValue: uint32(nonCoord[0]), AttemptContextHash: append([]byte(nil), prevHash[:]...), CoordinatorPackageProofs: [][]byte{proofA}}, + {SenderIDValue: uint32(nonCoord[1]), AttemptContextHash: append([]byte(nil), prevHash[:]...), CoordinatorPackageProofs: [][]byte{proofB}}, + } + sort.Slice(snaps, func(i, j int) bool { return snaps[i].SenderIDValue < snaps[j].SenderIDValue }) + for i := range snaps { + if len(snaps[i].Rejects) != 0 || len(snaps[i].Conflicts) != 0 || len(snaps[i].Overflows) != 0 { + t.Fatalf("snapshot %d must carry no accusation entries (pin causation to equivocation)", i) + } + } + bundle := &roast.TransitionMessage{ + AttemptContextHash: append([]byte(nil), prevHash[:]...), + CoordinatorIDValue: uint32(coordinator), + Bundle: snaps, + } + + // Drive the REAL policy through the REAL verifier. + coord := roast.NewInMemoryCoordinatorWithSigning(coordinator, coordSigner, verifier) + handle, err := coord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + next, err := coord.NextAttempt(handle, bundle, uint(threshold), keyGroupSeed) + if err != nil { + t.Fatalf("NextAttempt: %v", err) + } + + // ---- Assert INSTANT PERMANENT exclusion of the coordinator. ---- + if !containsMember(next.ExcludedSet, coordinator) { + t.Fatalf("equivocating coordinator %d must be permanently excluded; excluded=%v", coordinator, next.ExcludedSet) + } + if containsMember(next.IncludedSet, coordinator) { + t.Fatalf("excluded coordinator %d must drop from the next included set %v", coordinator, next.IncludedSet) + } + if containsMember(next.TransientlyParked, coordinator) { + t.Fatalf("coordinator %d must be excluded, not merely parked; parked=%v", coordinator, next.TransientlyParked) + } + wantIncluded := append([]group.MemberIndex{}, nonCoord...) + gotIncluded := append([]group.MemberIndex{}, next.IncludedSet...) + sort.Slice(gotIncluded, func(i, j int) bool { return gotIncluded[i] < gotIncluded[j] }) + if !memberSlicesEqualLocal(gotIncluded, wantIncluded) { + t.Fatalf("attempt 2 included = %v, want %v", gotIncluded, wantIncluded) + } + t.Logf("coordinator %d instantly + permanently excluded on two authentic distinct packages", coordinator) + + // ---- NON-VACUOUS negative control: same verifier, corrupted signature. ---- + // A single AUTHENTIC package plus a package whose real signature is corrupted + // (rejected by the real verifier) is only ONE distinct authentic body -> no + // equivocation, no exclusion. This proves the verifier is genuinely + // cryptographic (not NoOp) and the positive exclusion above required TWO + // authentic bodies. + pkgBad := newSignedPkg(bytes.Repeat([]byte{0xcd}, roast.TaprootMerkleRootLength)) // third distinct body + pkgBad.CoordinatorSignature[0] ^= 0x01 // break the real signature + if err := roast.AuthenticateSigningPackage(verifier, pkgBad, coordinator, prevHash[:]); !errors.Is(err, roast.ErrSignatureInvalid) { + t.Fatalf("real verifier must reject a corrupted signature (proves it is not NoOp); got %v", err) + } + proofBad, err := pkgBad.Marshal() + if err != nil { + t.Fatalf("marshal pkgBad: %v", err) + } + negSnaps := []roast.LocalEvidenceSnapshot{ + {SenderIDValue: uint32(coordinator), AttemptContextHash: append([]byte(nil), prevHash[:]...)}, + {SenderIDValue: uint32(nonCoord[0]), AttemptContextHash: append([]byte(nil), prevHash[:]...), CoordinatorPackageProofs: [][]byte{proofA}}, + {SenderIDValue: uint32(nonCoord[1]), AttemptContextHash: append([]byte(nil), prevHash[:]...), CoordinatorPackageProofs: [][]byte{proofBad}}, + } + sort.Slice(negSnaps, func(i, j int) bool { return negSnaps[i].SenderIDValue < negSnaps[j].SenderIDValue }) + negBundle := &roast.TransitionMessage{ + AttemptContextHash: append([]byte(nil), prevHash[:]...), + CoordinatorIDValue: uint32(coordinator), + Bundle: negSnaps, + } + handle2, err := coord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("begin attempt (negative control): %v", err) + } + nextNeg, err := coord.NextAttempt(handle2, negBundle, uint(threshold), keyGroupSeed) + if err != nil { + t.Fatalf("NextAttempt (negative control): %v", err) + } + if containsMember(nextNeg.ExcludedSet, coordinator) { + t.Fatalf("only one AUTHENTIC distinct body: coordinator %d must NOT be excluded; excluded=%v", coordinator, nextNeg.ExcludedSet) + } + t.Logf("negative control: one authentic + one corrupted package did NOT exclude the coordinator (verifier is real)") +} diff --git a/pkg/frost/signing/roast_runner_real_cgo_dropout_retry_frost_native_test.go b/pkg/frost/signing/roast_runner_real_cgo_dropout_retry_frost_native_test.go new file mode 100644 index 0000000000..2008328eef --- /dev/null +++ b/pkg/frost/signing/roast_runner_real_cgo_dropout_retry_frost_native_test.go @@ -0,0 +1,357 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "context" + "fmt" + "sort" + "strings" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + netlocal "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This test closes the highest-value ROAST-retry coverage gap: NO test combined +// REAL threshold crypto with an INDUCED signer failure AND a retry. The real-cgo +// e2es (roast_runner_real_cgo_multinode_e2e_frost_native_test.go) are happy-path +// only; the retry/parking machinery (next_attempt.go) is unit-tested with fakes. +// This wires them together end to end: +// +// attempt 1 (REAL crypto): a SELECTED signer withholds its round-2 share -> +// the elected coordinator (the aggregator) starves and fails; +// NextAttempt (REAL policy): the silent member is missing from the transition +// bundle senders, so it is transiently PARKED; +// attempt 2 (REAL crypto): the reshuffled subset (silent member excluded) +// aggregates a real BIP-340 signature. +// +// DETERMINISM in the single-process shared-engine harness. Subset selection is +// first-come-first-served over the responsive committers, and the one engine is +// shared by all local seats. To make attempt 1 fail deterministically (rather +// than depend on which seat commits first, or let a co-resident seat aggregate), +// attempt 1 runs ONLY {coordinator, target}: the target is therefore necessarily +// the coordinator's sole co-signer, and by withholding its share it starves the +// coordinator's collection. We assert on the COORDINATOR's failure (the +// aggregator) - a co-resident target may aggregate locally off the shared engine, +// which is a harness artifact of the shared engine, not a real outcome (a real +// per-node deployment gives the faulty node its own engine and it broadcasts +// nothing). The offline third seat is absent from attempt 1 and is supplied as a +// live bundle sender for the transition, exactly as a real non-selected-but-live +// member would broadcast its evidence snapshot. + +// shareDroppingBus wraps a RunnerBus and silently drops the wrapped seat's own +// round-2 share submissions, modelling a signer that goes silent mid-collection +// after being selected. All other traffic (commitments, the coordinator package, +// evidence) passes through, and inbound delivery via Subscribe is untouched. +type shareDroppingBus struct{ inner RunnerBus } + +func (b shareDroppingBus) Broadcast(msg RunnerMessage) { + if msg.Type == RunnerMsgShareSubmission { + return + } + b.inner.Broadcast(msg) +} + +func (b shareDroppingBus) Subscribe() *RunnerBusSubscriber { return b.inner.Subscribe() } + +// dropoutSeat bundles one seat's coordinator, attempt handle, binding, and runner +// so the test can run a chosen subset and later drive NextAttempt from the +// elected coordinator's coordinator instance. +type dropoutSeat struct { + member group.MemberIndex + coord roast.Coordinator + handle roast.AttemptHandle + binding *ActiveRoastAttempt + runner *interactiveSigningRunner +} + +func TestRealCgoInteractiveSigning_DropoutForcesNextAttemptAndReshuffledSubsetFinalizes(t *testing.T) { + setupRealCgoSignerState(t) + + engine := &buildTaggedTBTCSignerEngine{} + sessionID := fmt.Sprintf("real-cgo-dropout-retry-%d", realCgoSessionSeq.Add(1)) + + const n = 3 + const threshold uint16 = 2 + participantIDs := []byte{1, 2, 3} + included := []group.MemberIndex{1, 2, 3} + + // One real DKG; both attempts sign under the same key group. + keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold) + keyGroupSeed := []byte(keyGroup) + + var messageDigest [attempt.MessageDigestLength]byte + for i := range messageDigest { + messageDigest[i] = 0x42 + } + + attempt1Ctx, err := attempt.NewAttemptContext( + sessionID, keyGroup, keyGroupSeed, messageDigest, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt 1 context: %v", err) + } + + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + logger := &testutils.MockLogger{} + + // Resolve attempt 1's elected coordinator from a probe binding (the election + // is a property of the attempt context), so we can designate a NON-coordinator + // target to withhold its share and the offline seat. + probeCoord := roast.NewInMemoryCoordinatorWithSigning(1, signer, verifier) + probeHandle, err := probeCoord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("probe begin attempt: %v", err) + } + probeBinding, err := NewActiveRoastAttempt(probeCoord, probeHandle, attempt1Ctx, sessionID, nil, keyGroupSeed) + if err != nil { + t.Fatalf("probe binding: %v", err) + } + coordinator := probeBinding.ElectedCoordinator() + nonCoordinators := make([]group.MemberIndex, 0, n-1) + for _, m := range included { + if m != coordinator { + nonCoordinators = append(nonCoordinators, m) + } + } + sort.Slice(nonCoordinators, func(i, j int) bool { return nonCoordinators[i] < nonCoordinators[j] }) + target := nonCoordinators[0] // withholds its share in attempt 1 + offline := nonCoordinators[1] // absent in attempt 1, live sender for the transition + t.Logf("attempt 1: coordinator=%d target(silent)=%d offline=%d", coordinator, target, offline) + + // Shared operator identities + membership validator over all 3 seats, so any + // subset's broadcasts authenticate. + chainSigning := local_v1.Connect(n, n).Signing() + publicKeys := make(map[group.MemberIndex]*operator.PublicKey, n) + addresses := make([]chain.Address, 0, n) + for _, m := range included { + _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (seat %d): %v", m, err) + } + publicKeys[m] = publicKey + addresses = append(addresses, chainSigning.PublicKeyBytesToAddress( + operator.MarshalUncompressed(publicKey), + )) + } + validator := group.NewMembershipValidator(&testutils.MockLogger{}, addresses, chainSigning) + + newSeat := func(ctx context.Context, member group.MemberIndex, attemptCtx attempt.AttemptContext, dropShares bool) *dropoutSeat { + channel, err := netlocal.ConnectWithKey(publicKeys[member]). + BroadcastChannelFor("frost-roast-interactive-signing") + if err != nil { + t.Fatalf("broadcast channel (seat %d): %v", member, err) + } + var bus RunnerBus + bus, err = NewBroadcastChannelRunnerBus(ctx, logger, channel, validator) + if err != nil { + t.Fatalf("runner bus (seat %d): %v", member, err) + } + if dropShares { + bus = shareDroppingBus{inner: bus} + } + coord := roast.NewInMemoryCoordinatorWithSigning(member, signer, verifier) + handle, err := coord.BeginAttempt(attemptCtx) + if err != nil { + t.Fatalf("begin attempt (seat %d): %v", member, err) + } + binding, err := NewActiveRoastAttempt(coord, handle, attemptCtx, sessionID, nil, keyGroupSeed) + if err != nil { + t.Fatalf("active attempt (seat %d): %v", member, err) + } + collector := roast.NewRound2Collector(verifier) + runner, err := newInteractiveSigningRunner( + binding, member, threshold, engine, collector, coord, signer, bus, + ) + if err != nil { + t.Fatalf("runner (seat %d): %v", member, err) + } + return &dropoutSeat{member: member, coord: coord, handle: handle, binding: binding, runner: runner} + } + + // ---- Attempt 1: coordinator + silent target only; coordinator must fail. ---- + ctx1, cancel1 := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel1() + + coordSeat := newSeat(ctx1, coordinator, attempt1Ctx, false) + targetSeat := newSeat(ctx1, target, attempt1Ctx, true) // withholds its share + + coordErr := runSeatAsync(coordSeat, ctx1) + targetErr := runSeatAsync(targetSeat, ctx1) + <-coordErr.done + <-targetErr.done + + // The coordinator must fail SPECIFICALLY by starving on the missing share: it + // built + broadcast the package, produced its own round-2 share, then timed + // out collecting the target's. Pinning the failure to share collection (not a + // generic early error) is what makes this the withheld-share path. + if coordErr.err == nil { + t.Fatalf( + "attempt 1: the coordinator aggregated a signature despite the selected co-signer (seat %d) "+ + "withholding its share; expected the coordinator's collection to starve and fail", + target, + ) + } + if !strings.Contains(coordErr.err.Error(), "collect shares") { + t.Fatalf( + "attempt 1: coordinator must starve at round-2 share collection; got a different failure: %v", + coordErr.err, + ) + } + t.Logf("attempt 1 coordinator starved at share collection as expected: %v", coordErr.err) + + // Crucially, the target must actually have REACHED round 2 and produced the + // share it withheld -- otherwise the coordinator's timeout would be vacuous (an + // early target failure, e.g. an undelivered signing package or a pre-round-2 + // engine error, would starve the coordinator identically). Because only the + // target's OUTBOUND share is dropped, the target still receives the + // coordinator's share and aggregates locally off the shared engine; that local + // success -- or, failing that, its own collect-shares timeout -- is the proof + // it produced (and the bus withheld) a genuine round-2 share. A failure BEFORE + // round 2 must fail this test. + if targetErr.err != nil && !strings.Contains(targetErr.err.Error(), "collect shares") { + t.Fatalf( + "attempt 1: target seat %d must reach round 2 and produce its withheld share, but it "+ + "failed before round 2 (so the coordinator's starvation would be vacuous): %v", + target, targetErr.err, + ) + } + t.Logf("attempt 1 target reached round 2 and produced its withheld share (err=%v sigLen=%d)", + targetErr.err, len(targetErr.sig)) + + // ---- Transition: build the bundle from the LIVE senders (target absent). ---- + prevHash := attempt1Ctx.Hash() + senders := []group.MemberIndex{coordinator, offline} + sort.Slice(senders, func(i, j int) bool { return senders[i] < senders[j] }) + bundleSnapshots := make([]roast.LocalEvidenceSnapshot, 0, len(senders)) + for _, s := range senders { + bundleSnapshots = append(bundleSnapshots, roast.LocalEvidenceSnapshot{ + SenderIDValue: uint32(s), + AttemptContextHash: append([]byte{}, prevHash[:]...), + }) + } + bundle := &roast.TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: uint32(coordinator), + Bundle: bundleSnapshots, + } + + attempt2Ctx, err := coordSeat.coord.NextAttempt( + coordSeat.handle, bundle, uint(threshold), keyGroupSeed, + ) + if err != nil { + t.Fatalf("NextAttempt: %v", err) + } + + // ---- Assert the real parking policy excluded the silent target. ---- + if !containsMember(attempt2Ctx.TransientlyParked, target) { + t.Fatalf("silent target %d must be transiently parked; parked=%v", target, attempt2Ctx.TransientlyParked) + } + if containsMember(attempt2Ctx.IncludedSet, target) { + t.Fatalf("silent target %d must not be in attempt 2's included set %v", target, attempt2Ctx.IncludedSet) + } + wantIncluded := []group.MemberIndex{coordinator, offline} + sort.Slice(wantIncluded, func(i, j int) bool { return wantIncluded[i] < wantIncluded[j] }) + gotIncluded := append([]group.MemberIndex{}, attempt2Ctx.IncludedSet...) + sort.Slice(gotIncluded, func(i, j int) bool { return gotIncluded[i] < gotIncluded[j] }) + if !memberSlicesEqualLocal(gotIncluded, wantIncluded) { + t.Fatalf("attempt 2 included set = %v, want %v (silent target parked)", gotIncluded, wantIncluded) + } + if attempt2Ctx.AttemptNumber != attempt1Ctx.AttemptNumber+1 { + t.Fatalf("attempt 2 number = %d, want %d", attempt2Ctx.AttemptNumber, attempt1Ctx.AttemptNumber+1) + } + + // ---- Attempt 2: reshuffled subset {coordinator, offline} finalizes for real. ---- + ctx2, cancel2 := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel2() + + var seats2 []*dropoutSeat + for _, m := range attempt2Ctx.IncludedSet { + seats2 = append(seats2, newSeat(ctx2, m, attempt2Ctx, false)) + } + results := make([]seatResult, len(seats2)) + dones := make([]*seatRunResult, len(seats2)) + for i, s := range seats2 { + dones[i] = runSeatAsync(s, ctx2) + } + for i := range dones { + <-dones[i].done + results[i] = seatResult{sig: dones[i].sig, err: dones[i].err} + } + + winners := 0 + for i, r := range results { + switch { + case r.err == nil: + if len(r.sig) != 64 { + t.Fatalf("attempt 2 seat %d: want a 64-byte BIP-340 signature, got %d bytes", seats2[i].member, len(r.sig)) + } + winners++ + state, err := seats2[i].coord.State(seats2[i].handle) + if err != nil { + t.Fatalf("attempt 2 seat %d state: %v", seats2[i].member, err) + } + if state != roast.AttemptStateSucceeded { + t.Fatalf("attempt 2 seat %d aggregated but is not Succeeded: %v", seats2[i].member, state) + } + case isInteractiveAlreadyAggregated(r.err): + // Co-resident seat: the shared engine already finalized this attempt. + default: + t.Fatalf("attempt 2 seat %d failed unexpectedly: %v", seats2[i].member, r.err) + } + } + if winners != 1 { + t.Fatalf("attempt 2: expected exactly one seat to aggregate the reshuffled-subset signature, got %d", winners) + } +} + +type seatResult struct { + sig []byte + err error +} + +type seatRunResult struct { + done chan struct{} + sig []byte + err error +} + +// runSeatAsync runs a seat's runner in a goroutine and signals completion. +func runSeatAsync(s *dropoutSeat, ctx context.Context) *seatRunResult { + r := &seatRunResult{done: make(chan struct{})} + go func() { + r.sig, r.err = s.runner.Run(ctx) + close(r.done) + }() + return r +} + +func containsMember(set []group.MemberIndex, m group.MemberIndex) bool { + for _, x := range set { + if x == m { + return true + } + } + return false +} + +func memberSlicesEqualLocal(a, b []group.MemberIndex) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/frost/signing/roast_runner_real_cgo_invalid_share_exclusion_frost_native_test.go b/pkg/frost/signing/roast_runner_real_cgo_invalid_share_exclusion_frost_native_test.go new file mode 100644 index 0000000000..ba91cb492c --- /dev/null +++ b/pkg/frost/signing/roast_runner_real_cgo_invalid_share_exclusion_frost_native_test.go @@ -0,0 +1,289 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "context" + "errors" + "fmt" + "sort" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + netlocal "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This test closes the second real-crypto-under-failure gap: an INVALID signature +// share, detected by the REAL cgo engine, driving a PERMANENT exclusion. Prior +// coverage was disjoint - the real engine's share-verification and the f+1 +// reject-blame exclusion policy (next_attempt.go) were only ever exercised +// separately, the latter with fakes. +// +// Flow (companion to the dropout/transient-park test, which parks; this one +// EXCLUDES): +// attempt 1 (REAL crypto): a selected signer submits a structurally valid but +// cryptographically WRONG round-2 share; the coordinator's real aggregate +// fails with a typed share-verification error that NAMES the culprit; +// NextAttempt (REAL policy): an f+1 reject-accusation quorum against the culprit +// PERMANENTLY excludes it (ExcludedSet, not a transient park); +// attempt 2 (REAL crypto): the surviving subset aggregates a real BIP-340 +// signature without the excluded member. +// +// Determinism: as in the dropout test, attempt 1 runs only {coordinator, target} +// so the target is necessarily the coordinator's co-signer and its bad share is +// aggregated (not observed away). The reject quorum is supplied from the genuine +// verdict - the target's share IS invalid (the coordinator's real aggregate +// proves it), so every honest observer would reject it; we encode the +// ExclusionAccuserQuorum-many accusers a live deployment would produce. + +// corruptingRound2Engine wraps the engine so the wrapped seat's round-2 share is +// mangled after the engine produces it: still a well-formed scalar (low byte +// flipped), but the wrong value. The seat then signs and broadcasts that share, +// modelling an invalid-share submitter. Every other engine call passes through +// the embedded engine unchanged. +type corruptingRound2Engine struct { + interactiveSigningEngine +} + +func (e corruptingRound2Engine) InteractiveRound2( + sessionID string, attemptID string, memberIdentifier uint16, signingPackage []byte, +) ([]byte, error) { + share, err := e.interactiveSigningEngine.InteractiveRound2(sessionID, attemptID, memberIdentifier, signingPackage) + if err != nil || len(share) == 0 { + return share, err + } + corrupted := append([]byte{}, share...) + corrupted[len(corrupted)-1] ^= 0x01 // valid scalar, wrong value + return corrupted, nil +} + +func TestRealCgoInteractiveSigning_InvalidShareBlameForcesPermanentExclusion(t *testing.T) { + setupRealCgoSignerState(t) + + engine := &buildTaggedTBTCSignerEngine{} + sessionID := fmt.Sprintf("real-cgo-invalid-share-%d", realCgoSessionSeq.Add(1)) + + const n = 3 + const threshold uint16 = 2 + participantIDs := []byte{1, 2, 3} + included := []group.MemberIndex{1, 2, 3} + + keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold) + keyGroupSeed := []byte(keyGroup) + + var messageDigest [attempt.MessageDigestLength]byte + for i := range messageDigest { + messageDigest[i] = 0x42 + } + + attempt1Ctx, err := attempt.NewAttemptContext( + sessionID, keyGroup, keyGroupSeed, messageDigest, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt 1 context: %v", err) + } + + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + logger := &testutils.MockLogger{} + + probeCoord := roast.NewInMemoryCoordinatorWithSigning(1, signer, verifier) + probeHandle, err := probeCoord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("probe begin attempt: %v", err) + } + probeBinding, err := NewActiveRoastAttempt(probeCoord, probeHandle, attempt1Ctx, sessionID, nil, keyGroupSeed) + if err != nil { + t.Fatalf("probe binding: %v", err) + } + coordinator := probeBinding.ElectedCoordinator() + + nonCoordinators := make([]group.MemberIndex, 0, n-1) + for _, m := range included { + if m != coordinator { + nonCoordinators = append(nonCoordinators, m) + } + } + sort.Slice(nonCoordinators, func(i, j int) bool { return nonCoordinators[i] < nonCoordinators[j] }) + target := nonCoordinators[0] // submits the invalid share + offline := nonCoordinators[1] // absent in attempt 1, second live accuser + attempt-2 signer + t.Logf("attempt 1: coordinator=%d target(invalid-share)=%d offline=%d", coordinator, target, offline) + + chainSigning := local_v1.Connect(n, n).Signing() + publicKeys := make(map[group.MemberIndex]*operator.PublicKey, n) + addresses := make([]chain.Address, 0, n) + for _, m := range included { + _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (seat %d): %v", m, err) + } + publicKeys[m] = publicKey + addresses = append(addresses, chainSigning.PublicKeyBytesToAddress( + operator.MarshalUncompressed(publicKey), + )) + } + validator := group.NewMembershipValidator(&testutils.MockLogger{}, addresses, chainSigning) + + newSeat := func(ctx context.Context, member group.MemberIndex, attemptCtx attempt.AttemptContext, corruptShare bool) *dropoutSeat { + channel, err := netlocal.ConnectWithKey(publicKeys[member]). + BroadcastChannelFor("frost-roast-interactive-signing") + if err != nil { + t.Fatalf("broadcast channel (seat %d): %v", member, err) + } + bus, err := NewBroadcastChannelRunnerBus(ctx, logger, channel, validator) + if err != nil { + t.Fatalf("runner bus (seat %d): %v", member, err) + } + var eng interactiveSigningEngine = engine + if corruptShare { + eng = corruptingRound2Engine{interactiveSigningEngine: engine} + } + coord := roast.NewInMemoryCoordinatorWithSigning(member, signer, verifier) + handle, err := coord.BeginAttempt(attemptCtx) + if err != nil { + t.Fatalf("begin attempt (seat %d): %v", member, err) + } + binding, err := NewActiveRoastAttempt(coord, handle, attemptCtx, sessionID, nil, keyGroupSeed) + if err != nil { + t.Fatalf("active attempt (seat %d): %v", member, err) + } + collector := roast.NewRound2Collector(verifier) + runner, err := newInteractiveSigningRunner(binding, member, threshold, eng, collector, coord, signer, bus) + if err != nil { + t.Fatalf("runner (seat %d): %v", member, err) + } + return &dropoutSeat{member: member, coord: coord, handle: handle, binding: binding, runner: runner} + } + + // ---- Attempt 1: coordinator + invalid-share target; aggregate must name it. ---- + ctx1, cancel1 := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel1() + + coordSeat := newSeat(ctx1, coordinator, attempt1Ctx, false) + targetSeat := newSeat(ctx1, target, attempt1Ctx, true) + coordRes := runSeatAsync(coordSeat, ctx1) + targetRes := runSeatAsync(targetSeat, ctx1) + <-coordRes.done + <-targetRes.done + + // The REAL engine's aggregate must detect the invalid share and name the + // target. Accept the detection from whichever co-resident seat aggregated it + // (shared engine); both run aggregate and see the same bad share. + var svErr *InteractiveAggregateShareVerificationError + switch { + case errors.As(coordRes.err, &svErr): + case errors.As(targetRes.err, &svErr): + default: + t.Fatalf( + "attempt 1: want a share-verification failure naming the culprit; coord err=%v target err=%v", + coordRes.err, targetRes.err, + ) + } + if !containsUint16(svErr.CandidateCulprits, uint16(target)) { + t.Fatalf("aggregate must name target %d as a candidate culprit; culprits=%v", target, svErr.CandidateCulprits) + } + t.Logf("attempt 1: real aggregate named culprit(s) %v (target=%d)", svErr.CandidateCulprits, target) + + // ---- Transition: an f+1 reject-accusation quorum against the target. ---- + quorum := roast.ExclusionAccuserQuorum(uint(n), uint(threshold)) // = n - t + 1 = 2 + accusers := []group.MemberIndex{coordinator, offline} + if uint(len(accusers)) < quorum { + t.Fatalf("test setup: need >= %d accusers, have %d", quorum, len(accusers)) + } + sort.Slice(accusers, func(i, j int) bool { return accusers[i] < accusers[j] }) + + prevHash := attempt1Ctx.Hash() + snapshots := make([]roast.LocalEvidenceSnapshot, 0, len(accusers)) + for _, a := range accusers { + snapshots = append(snapshots, roast.LocalEvidenceSnapshot{ + SenderIDValue: uint32(a), + AttemptContextHash: append([]byte{}, prevHash[:]...), + Rejects: []roast.RejectEntry{{ + Sender: target, // the accused + Reason: "aggregate_share_verification_failed", + Count: 1, + }}, + }) + } + bundle := &roast.TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: uint32(coordinator), + Bundle: snapshots, + } + + attempt2Ctx, err := coordSeat.coord.NextAttempt(coordSeat.handle, bundle, uint(threshold), keyGroupSeed) + if err != nil { + t.Fatalf("NextAttempt: %v", err) + } + + // ---- Assert PERMANENT exclusion (not a transient park). ---- + if !containsMember(attempt2Ctx.ExcludedSet, target) { + t.Fatalf("invalid-share target %d must be PERMANENTLY excluded; excluded=%v", target, attempt2Ctx.ExcludedSet) + } + if containsMember(attempt2Ctx.TransientlyParked, target) { + t.Fatalf("target %d must be excluded, not merely parked; parked=%v", target, attempt2Ctx.TransientlyParked) + } + if containsMember(attempt2Ctx.IncludedSet, target) { + t.Fatalf("excluded target %d must not be in attempt 2's included set %v", target, attempt2Ctx.IncludedSet) + } + wantIncluded := []group.MemberIndex{coordinator, offline} + sort.Slice(wantIncluded, func(i, j int) bool { return wantIncluded[i] < wantIncluded[j] }) + gotIncluded := append([]group.MemberIndex{}, attempt2Ctx.IncludedSet...) + sort.Slice(gotIncluded, func(i, j int) bool { return gotIncluded[i] < gotIncluded[j] }) + if !memberSlicesEqualLocal(gotIncluded, wantIncluded) { + t.Fatalf("attempt 2 included set = %v, want %v (culprit excluded)", gotIncluded, wantIncluded) + } + + // ---- Attempt 2: surviving subset finalizes for real without the culprit. ---- + ctx2, cancel2 := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel2() + + var seats2 []*dropoutSeat + for _, m := range attempt2Ctx.IncludedSet { + seats2 = append(seats2, newSeat(ctx2, m, attempt2Ctx, false)) + } + dones := make([]*seatRunResult, len(seats2)) + for i, s := range seats2 { + dones[i] = runSeatAsync(s, ctx2) + } + winners := 0 + for i := range dones { + <-dones[i].done + switch { + case dones[i].err == nil: + if len(dones[i].sig) != 64 { + t.Fatalf("attempt 2 seat %d: want a 64-byte signature, got %d", seats2[i].member, len(dones[i].sig)) + } + winners++ + state, err := seats2[i].coord.State(seats2[i].handle) + if err != nil { + t.Fatalf("attempt 2 seat %d state: %v", seats2[i].member, err) + } + if state != roast.AttemptStateSucceeded { + t.Fatalf("attempt 2 seat %d not Succeeded: %v", seats2[i].member, state) + } + case isInteractiveAlreadyAggregated(dones[i].err): + default: + t.Fatalf("attempt 2 seat %d failed unexpectedly: %v", seats2[i].member, dones[i].err) + } + } + if winners != 1 { + t.Fatalf("attempt 2: expected exactly one seat to aggregate the surviving-subset signature, got %d", winners) + } +} + +func containsUint16(set []uint16, m uint16) bool { + for _, x := range set { + if x == m { + return true + } + } + return false +} diff --git a/pkg/frost/signing/roast_runner_real_cgo_multinode_e2e_frost_native_test.go b/pkg/frost/signing/roast_runner_real_cgo_multinode_e2e_frost_native_test.go new file mode 100644 index 0000000000..e9bcf7d9f5 --- /dev/null +++ b/pkg/frost/signing/roast_runner_real_cgo_multinode_e2e_frost_native_test.go @@ -0,0 +1,285 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "bytes" + "context" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + netlocal "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This file is the "shape (A)" real-crypto multi-node-style e2e (RFC-21 Phase 7.3 +// pre-production milestone): n interactive signing RUNNERS, each over its own REAL +// pkg/net BroadcastChannel bus, driving the REAL cgo FROST engine to a REAL BIP-340 +// signature - all in one process. It is the union of the two half-faked e2es: +// +// - roast_runner_bus_net_e2e_frost_native_test.go proves the runner+transport with +// a FAKE engine (no crypto); +// - roast_real_cgo_interactive_e2e_frost_native_test.go proves the real engine with +// NO runner and NO transport (direct engine calls, one process). +// +// Neither exercises the real-engine <-> real-transport <-> runner seam together; this +// does. Per the Codex design consult (2026-06-21), shape (A) is the required +// pre-cutover harness; the single-OS-process / one-shared-engine simplification is the +// engine's process-global constraint (ENGINE_STATE is a process-global OnceLock), +// not a correctness gap - the multi-seat fix (#4098) is exactly what lets one engine +// serve every local member. Separate-process fidelity (shape B) is a later/nightly +// concern that only adds PROCESS-boundary coverage (per-node state isolation, per-process +// linking/env, libp2p outer framing), not protocol-message coverage. +// +// SERIALIZATION (the point of the test): although all runners share one engine, peer +// messages still make a real byte round-trip. The runner broadcasts byte payloads; the +// net bus wraps them in a runnerTransportMessage and calls channel.Send, and the local +// pkg/net channel Marshal()s then Unmarshal()s into a fresh payload object. So a peer's +// commitments, signing package, and shares are serialized over the production transport +// adapter. Two caveats: (i) pkg/net/local does not exercise libp2p's outer protobuf/ +// pubsub path (that is shape B); (ii) a sender keeps its OWN produced value locally by +// design, so the round-trip coverage is on cross-runner (peer) messages. +// +// VERIFICATION ORACLE: the engine self-verifies the aggregate internally, so a non-error +// signature is a valid BIP-340 signature over the message (an existing low-level cgo test +// externally verifies the aggregate crypto; this test does not widen the engine surface +// with a keyGroup->pubkey accessor just to re-verify). The assertions here add that every +// seat obtains the SAME non-empty 64-byte signature and reaches Succeeded. + +// realCgoNetSigningHarness wires n interactive signing runners over n real pkg/net runner +// buses against ONE real cgo engine. +type realCgoNetSigningHarness struct { + runners []*interactiveSigningRunner + coords []roast.Coordinator + handles []roast.AttemptHandle +} + +// buildRealCgoNetHarness builds an n-seat interactive signing round over the real pkg/net +// transport, driving the real cgo engine. It runs a real DKG up front (skip-guarded: an +// absent/stale libfrost_tbtc skips the test there), then binds every seat's attempt +// context to the DKG key group so the Go RFC-21 coordinator election and the engine's own +// derivation agree. +func buildRealCgoNetHarness( + t *testing.T, + ctx context.Context, + engine *buildTaggedTBTCSignerEngine, + sessionID string, + n int, + threshold uint16, +) realCgoNetSigningHarness { + t.Helper() + + participantIDs := make([]byte, 0, n) + included := make([]group.MemberIndex, 0, n) + for i := 1; i <= n; i++ { + participantIDs = append(participantIDs, byte(i)) + included = append(included, group.MemberIndex(i)) + } + + // 1. Real DKG that persists a key group under sessionID. The returned handle string + // is BOTH the id the engine resolves on InteractiveSessionOpen AND the seed material: + // the engine derives the coordinator-shuffle seed as SHA256(keyGroup || sessionID || + // messageDigest), and keep-core's attempt.DeriveAttemptSeed hashes its + // dkgGroupPublicKey argument identically - so passing []byte(keyGroup) as the + // dkgGroupPublicKey makes the two independent derivations agree (the runner fails + // closed if they do not). + keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold) + keyGroupSeed := []byte(keyGroup) + + var messageDigest [attempt.MessageDigestLength]byte + for i := range messageDigest { + messageDigest[i] = 0x42 + } + + attemptCtx, err := attempt.NewAttemptContext( + sessionID, keyGroup, keyGroupSeed, messageDigest, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt context: %v", err) + } + + // One operator per seat; the MembershipValidator maps each seat to that operator's + // address so the bus authenticates every broadcast's claimed seat against the + // authenticated sender key (same wiring as the fake-engine net harness). + chainSigning := local_v1.Connect(n, n).Signing() + publicKeys := make([]*operator.PublicKey, n) + addresses := make([]chain.Address, n) + for i := 0; i < n; i++ { + _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (seat %d): %v", i+1, err) + } + publicKeys[i] = publicKey + addresses[i] = chainSigning.PublicKeyBytesToAddress( + operator.MarshalUncompressed(publicKey), + ) + } + validator := group.NewMembershipValidator(&testutils.MockLogger{}, addresses, chainSigning) + + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + logger := &testutils.MockLogger{} + + h := realCgoNetSigningHarness{} + for i := 0; i < n; i++ { + member := group.MemberIndex(i + 1) + + channel, err := netlocal.ConnectWithKey(publicKeys[i]). + BroadcastChannelFor("frost-roast-interactive-signing") + if err != nil { + t.Fatalf("broadcast channel (seat %d): %v", member, err) + } + bus, err := NewBroadcastChannelRunnerBus(ctx, logger, channel, validator) + if err != nil { + t.Fatalf("runner bus (seat %d): %v", member, err) + } + + coord := roast.NewInMemoryCoordinatorWithSigning(member, signer, verifier) + handle, err := coord.BeginAttempt(attemptCtx) + if err != nil { + t.Fatalf("begin attempt (seat %d): %v", member, err) + } + ara, err := NewActiveRoastAttempt(coord, handle, attemptCtx, sessionID, nil, keyGroupSeed) + if err != nil { + t.Fatalf("active attempt (seat %d): %v", member, err) + } + collector := roast.NewRound2Collector(verifier) + // SAME engine instance for every seat - the multi-seat path keys interactive + // state by member, so one engine serves all local seats. + runner, err := newInteractiveSigningRunner( + ara, member, threshold, engine, collector, coord, signer, bus, + ) + if err != nil { + t.Fatalf("runner (seat %d): %v", member, err) + } + + h.coords = append(h.coords, coord) + h.handles = append(h.handles, handle) + h.runners = append(h.runners, runner) + } + return h +} + +// runAllAndAssertRealSignature runs every seat's runner concurrently against the shared +// engine and asserts the shape-(A) outcome. +// +// Because all seats share ONE engine and the engine's aggregate completion marker is +// per-ATTEMPT (keyed by attempt_id, message, and taproot root - NOT per member), the +// first seat to reach InteractiveAggregate produces the signature and marks the attempt +// finalized; every co-resident seat then observes interactive_attempt_already_aggregated. +// That is the shared-engine boundary, not a protocol failure: in production each node has +// its own engine and aggregates the shares it collected independently. Crucially, every +// seat still drives its FULL transport (broadcasting its commitments and share, and +// collecting peers' commitments/package/shares over the real pkg/net bus) BEFORE the +// aggregate step - so the transport seam this test exists for is exercised by all seats +// regardless of which one wins the aggregate. +// +// So the assertions are: exactly one seat aggregates a real 64-byte BIP-340 signature and +// reaches Succeeded (the engine produced the signature once, through the full runner + +// transport stack), and every other seat reached aggregate and observed the per-attempt +// idempotency marker - no seat fails for any other reason. +func (h realCgoNetSigningHarness) runAllAndAssertRealSignature(t *testing.T, ctx context.Context) { + t.Helper() + sigs := make([][]byte, len(h.runners)) + errs := make([]error, len(h.runners)) + var wg sync.WaitGroup + for i := range h.runners { + wg.Add(1) + go func(idx int) { + defer wg.Done() + sigs[idx], errs[idx] = h.runners[idx].Run(ctx) + }(i) + } + wg.Wait() + + var winningSignature []byte + winners := 0 + for i := range h.runners { + member := i + 1 + switch { + case errs[i] == nil: + if len(sigs[i]) != 64 { + t.Fatalf("seat %d: expected a 64-byte BIP-340 signature, got %d bytes", member, len(sigs[i])) + } + winners++ + if winningSignature == nil { + winningSignature = sigs[i] + } else if !bytes.Equal(sigs[i], winningSignature) { + t.Fatalf("seat %d produced a different signature than another aggregating seat", member) + } + state, err := h.coords[i].State(h.handles[i]) + if err != nil { + t.Fatalf("seat %d state: %v", member, err) + } + if state != roast.AttemptStateSucceeded { + t.Fatalf("seat %d aggregated but did not reach Succeeded, got %v", member, state) + } + case isInteractiveAlreadyAggregated(errs[i]): + // Expected shared-engine outcome: this seat completed all transport and + // reached aggregate, where a co-resident seat had already finalized the + // attempt. The per-attempt idempotency marker is exactly what rejects it. + default: + t.Fatalf("seat %d run failed for an unexpected reason: %v", member, errs[i]) + } + } + if winners != 1 { + t.Fatalf( + "expected exactly one seat to aggregate the signature against the shared engine, got %d", + winners, + ) + } +} + +// isInteractiveAlreadyAggregated reports whether an aggregate error is the engine's +// per-attempt idempotency refusal - the expected outcome when a co-resident seat (sharing +// the single process-global engine) already finalized the attempt. Matched on the error +// text surfaced through the FFI bridge; test-only shared-engine detection, not production +// control flow. +func isInteractiveAlreadyAggregated(err error) bool { + return err != nil && strings.Contains(err.Error(), "interactive_attempt_already_aggregated") +} + +// TestRealCgoInteractiveSigning_NetTransport_FullIncludedRound runs a complete interactive +// signing round over the REAL pkg/net transport against the REAL cgo engine with a +// full-included attempt (group size == threshold == 2, every seat signs) - the real-crypto +// counterpart of TestInteractiveSigningRunner_NetTransport_FullIncludedRound. It proves the +// real engine's commitments / signing package / shares serialize over the production +// transport adapter and aggregate to a real BIP-340 signature. +func TestRealCgoInteractiveSigning_NetTransport_FullIncludedRound(t *testing.T) { + setupRealCgoSignerState(t) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + engine := &buildTaggedTBTCSignerEngine{} + sessionID := fmt.Sprintf("real-cgo-multinode-full-%d", realCgoSessionSeq.Add(1)) + buildRealCgoNetHarness(t, ctx, engine, sessionID, 2, 2). + runAllAndAssertRealSignature(t, ctx) +} + +// TestRealCgoInteractiveSigning_NetTransport_ThresholdSubsetRound runs the round over the +// real transport against the real engine with an oversized included set (group size 3, +// threshold 2): the coordinator finalizes over a t-subset and the remaining committed seat +// is an observer (RFC-21 Phase 7.3 t-of-included). Every seat still obtains the same +// signature and reaches Succeeded, proving the subset/observer flow works with real crypto +// across the production transport. +func TestRealCgoInteractiveSigning_NetTransport_ThresholdSubsetRound(t *testing.T) { + setupRealCgoSignerState(t) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + engine := &buildTaggedTBTCSignerEngine{} + sessionID := fmt.Sprintf("real-cgo-multinode-subset-%d", realCgoSessionSeq.Add(1)) + buildRealCgoNetHarness(t, ctx, engine, sessionID, 3, 2). + runAllAndAssertRealSignature(t, ctx) +} diff --git a/pkg/frost/signing/roast_runner_real_cgo_overflow_park_frost_native_test.go b/pkg/frost/signing/roast_runner_real_cgo_overflow_park_frost_native_test.go new file mode 100644 index 0000000000..3e330b5411 --- /dev/null +++ b/pkg/frost/signing/roast_runner_real_cgo_overflow_park_frost_native_test.go @@ -0,0 +1,274 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "fmt" + "sort" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This test closes the fifth real-under-failure gap: a member that FLOODS a +// receive loop faster than it drains genuinely OVERFLOWS the bounded inbound +// channel, and that real, recorded overflow evidence - carried by an f+1 quorum +// of honest observers - drives a TRANSIENT PARK of the flooder in the next +// attempt (transport pressure costs one attempt of liveness, then the member +// rejoins), NOT a permanent exclusion. Prior coverage was disjoint: the overflow +// primitive (enqueueOrRecordOverflow, evidence_overflow.go) was unit-tested with +// synthetic recorder-count assertions, and the park policy (next_attempt.go) was +// unit-tested with hand-authored OverflowEntry bundles - never wired together, so +// the overflow evidence the policy parked on was never the evidence the real +// primitive produced. +// +// This wires them end to end: +// +// OVERFLOW (real primitive): two honest observers each drive the REAL +// enqueueOrRecordOverflow against a REAL full bounded channel with messages +// from the flooder; each channel rejects the enqueue and each observer's REAL +// bounded recorder records overflow against the flooder. +// PARK (real policy): each observer's recorded Evidence is turned into a REAL +// LocalEvidenceSnapshot; an f+1 overflow-accusation quorum drives NextAttempt +// to TRANSIENTLY PARK the flooder (not exclude it). The flooder is itself a +// bundle sender, so silence-parking cannot account for the park - only the +// overflow quorum can. +// REINSTATE (transient proof): a following attempt with no accusations +// re-includes the parked flooder, proving the park is transient, not permanent +// exclusion. +// +// A quorum-gate negative control (a single overflow accuser, below f+1) does NOT +// park the flooder, proving the quorum is genuinely enforced. +// +// Determinism: overflow is a transport phenomenon adjudicated in pure Go - no +// FROST rounds, bus, or shared engine - so the flood is driven synchronously +// against a pre-filled channel (every enqueue after the first deterministically +// hits the overflow branch) and the policy is a pure function of the bundle. A +// real DKG is run only for an authentic attempt context, as in the sibling +// real-cgo tests; it is not load-bearing for the overflow/park logic. + +// recordRealOverflow drives the REAL enqueueOrRecordOverflow primitive against a +// REAL pre-filled bounded channel `floodCount` times with messages from +// `flooder`, and returns the observer's genuinely-recorded evidence plus the +// number of enqueues the full channel rejected. Every attempt after the channel +// is filled falls to the overflow branch, so the recorder observes real, +// primitive-produced overflow - not a hand-set counter. +func recordRealOverflow(t *testing.T, flooder group.MemberIndex, floodCount int) (attempt.Evidence, int) { + t.Helper() + ch := make(chan *buildTaggedTBTCSignerRoundContributionMessage, 1) + ch <- &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 0xff} // fill it; no consumer drains + recorder := attempt.NewBoundedRecorder() + + rejected := 0 + for i := 0; i < floodCount; i++ { + enqueued := enqueueOrRecordOverflow( + &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: uint32(flooder)}, + ch, + recorder, + ) + if !enqueued { + rejected++ + } + } + return recorder.Snapshot(), rejected +} + +// overflowCountFor returns the OverflowEntry count a snapshot reports against +// `accused` (0 if none), and whether such an entry exists. +func overflowCountFor(snap *roast.LocalEvidenceSnapshot, accused group.MemberIndex) (uint, bool) { + for _, e := range snap.Overflows { + if e.Sender == accused { + return e.Count, true + } + } + return 0, false +} + +func TestRealCgoInteractiveSigning_TransportOverflowTransientlyParksFlooder(t *testing.T) { + setupRealCgoSignerState(t) + + engine := &buildTaggedTBTCSignerEngine{} + sessionID := fmt.Sprintf("real-cgo-overflow-park-%d", realCgoSessionSeq.Add(1)) + + const n = 3 + const threshold uint16 = 2 + participantIDs := []byte{1, 2, 3} + included := []group.MemberIndex{1, 2, 3} + + // Authentic attempt context (family consistency; not load-bearing here). + keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold) + keyGroupSeed := []byte(keyGroup) + + var messageDigest [attempt.MessageDigestLength]byte + for i := range messageDigest { + messageDigest[i] = 0x42 + } + attempt1Ctx, err := attempt.NewAttemptContext( + sessionID, keyGroup, keyGroupSeed, messageDigest, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt 1 context: %v", err) + } + + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + + // Resolve the deterministic elected coordinator so the flooder is a + // non-coordinator (matches the sibling structure; the overflow park itself is + // coordinator-agnostic). + probeCoord := roast.NewInMemoryCoordinatorWithSigning(1, signer, verifier) + probeHandle, err := probeCoord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("probe begin attempt: %v", err) + } + probeBinding, err := NewActiveRoastAttempt(probeCoord, probeHandle, attempt1Ctx, sessionID, nil, keyGroupSeed) + if err != nil { + t.Fatalf("probe binding: %v", err) + } + coordinator := probeBinding.ElectedCoordinator() + nonCoord := make([]group.MemberIndex, 0, n-1) + for _, m := range included { + if m != coordinator { + nonCoord = append(nonCoord, m) + } + } + sort.Slice(nonCoord, func(i, j int) bool { return nonCoord[i] < nonCoord[j] }) + flooder := nonCoord[0] + // The two honest overflow accusers (f+1 = n-t+1 = 2 for n=3, t=2). + observers := []group.MemberIndex{coordinator, nonCoord[1]} + t.Logf("coordinator=%d flooder=%d observers=%v", coordinator, flooder, observers) + + quorum := roast.ExclusionAccuserQuorum(uint(n), uint(threshold)) + if uint(len(observers)) < quorum { + t.Fatalf("test setup: need >= %d overflow accusers, have %d", quorum, len(observers)) + } + + prevHash := attempt1Ctx.Hash() + + // ---- OVERFLOW: each observer genuinely overflows the real bounded channel. ---- + const floodCount = 12 // > OverflowQuotaDefault (8); the channel holds 1 and never drains + observerSnaps := make([]roast.LocalEvidenceSnapshot, 0, len(observers)) + for _, obs := range observers { + evidence, rejected := recordRealOverflow(t, flooder, floodCount) + + // FAULT REACHED: the channel really rejected enqueues, and the real + // recorder really recorded overflow against the flooder - not a + // hand-authored entry. + if rejected == 0 { + t.Fatalf("observer %d: the full channel never rejected an enqueue; overflow was not reached", obs) + } + if got := evidence.Overflows[flooder]; got == 0 { + t.Fatalf("observer %d: recorder shows no overflow against flooder %d", obs, flooder) + } + snap := roast.NewLocalEvidenceSnapshot(obs, prevHash, evidence) + // The real recorded overflow must have propagated into the wire snapshot. + count, ok := overflowCountFor(snap, flooder) + if !ok || count == 0 { + t.Fatalf("observer %d snapshot must carry an overflow entry (count>0) against flooder %d; overflows=%v", + obs, flooder, snap.Overflows) + } + observerSnaps = append(observerSnaps, *snap) + } + + // The flooder is ALSO a bundle sender (an empty self-snapshot), so it cannot + // be silence-parked: any park it receives is due to the overflow quorum alone. + flooderSnap := roast.NewLocalEvidenceSnapshot(flooder, prevHash, attempt.Evidence{}) + + buildBundle := func(snaps []roast.LocalEvidenceSnapshot) *roast.TransitionMessage { + sorted := append([]roast.LocalEvidenceSnapshot{}, snaps...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].SenderIDValue < sorted[j].SenderIDValue }) + return &roast.TransitionMessage{ + AttemptContextHash: append([]byte(nil), prevHash[:]...), + CoordinatorIDValue: uint32(coordinator), + Bundle: sorted, + } + } + + bundle := buildBundle(append(append([]roast.LocalEvidenceSnapshot{}, observerSnaps...), *flooderSnap)) + + // ---- PARK: an f+1 overflow quorum transiently parks the flooder. ---- + coord := roast.NewInMemoryCoordinatorWithSigning(coordinator, signer, verifier) + handle, err := coord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + attempt2Ctx, err := coord.NextAttempt(handle, bundle, uint(threshold), keyGroupSeed) + if err != nil { + t.Fatalf("NextAttempt: %v", err) + } + + if !containsMember(attempt2Ctx.TransientlyParked, flooder) { + t.Fatalf("flooder %d must be transiently parked; parked=%v", flooder, attempt2Ctx.TransientlyParked) + } + if containsMember(attempt2Ctx.ExcludedSet, flooder) { + t.Fatalf("overflow must PARK, not permanently exclude; flooder %d in excluded=%v", flooder, attempt2Ctx.ExcludedSet) + } + if containsMember(attempt2Ctx.IncludedSet, flooder) { + t.Fatalf("parked flooder %d must not be in attempt 2's included set %v", flooder, attempt2Ctx.IncludedSet) + } + if attempt2Ctx.AttemptNumber != attempt1Ctx.AttemptNumber+1 { + t.Fatalf("attempt 2 number = %d, want %d", attempt2Ctx.AttemptNumber, attempt1Ctx.AttemptNumber+1) + } + t.Logf("flooder %d transiently parked (excluded=%v included=%v parked=%v)", + flooder, attempt2Ctx.ExcludedSet, attempt2Ctx.IncludedSet, attempt2Ctx.TransientlyParked) + + // ---- REINSTATE: the following attempt with no accusations re-includes the + // parked flooder, proving the park is transient (not permanent exclusion). ---- + prevHash2 := attempt2Ctx.Hash() + reinstateSnaps := make([]roast.LocalEvidenceSnapshot, 0, len(attempt2Ctx.IncludedSet)) + for _, m := range attempt2Ctx.IncludedSet { + reinstateSnaps = append(reinstateSnaps, *roast.NewLocalEvidenceSnapshot(m, prevHash2, attempt.Evidence{})) + } + sort.Slice(reinstateSnaps, func(i, j int) bool { return reinstateSnaps[i].SenderIDValue < reinstateSnaps[j].SenderIDValue }) + reinstateBundle := &roast.TransitionMessage{ + AttemptContextHash: append([]byte(nil), prevHash2[:]...), + CoordinatorIDValue: uint32(coordinator), + Bundle: reinstateSnaps, + } + handle2, err := coord.BeginAttempt(attempt2Ctx) + if err != nil { + t.Fatalf("begin attempt (reinstate): %v", err) + } + attempt3Ctx, err := coord.NextAttempt(handle2, reinstateBundle, uint(threshold), keyGroupSeed) + if err != nil { + t.Fatalf("NextAttempt (reinstate): %v", err) + } + if !containsMember(attempt3Ctx.IncludedSet, flooder) { + t.Fatalf("transiently-parked flooder %d must rejoin the following attempt; included=%v parked=%v", + flooder, attempt3Ctx.IncludedSet, attempt3Ctx.TransientlyParked) + } + if containsMember(attempt3Ctx.TransientlyParked, flooder) { + t.Fatalf("flooder %d must not remain parked after reinstatement; parked=%v", flooder, attempt3Ctx.TransientlyParked) + } + t.Logf("flooder %d reinstated on attempt 3 (included=%v) - the park was transient", flooder, attempt3Ctx.IncludedSet) + + // ---- QUORUM GATE (negative control): a single overflow accuser (below f+1) + // does NOT park the flooder. Same real overflow evidence, one accuser. ---- + singleEvidence, rejected := recordRealOverflow(t, flooder, floodCount) + if rejected == 0 || singleEvidence.Overflows[flooder] == 0 { + t.Fatalf("single-accuser control: overflow evidence was not genuinely produced") + } + singleAccuserBundle := buildBundle([]roast.LocalEvidenceSnapshot{ + *roast.NewLocalEvidenceSnapshot(observers[0], prevHash, singleEvidence), // lone accuser + *roast.NewLocalEvidenceSnapshot(observers[1], prevHash, attempt.Evidence{}), + *flooderSnap, + }) + handle3, err := coord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("begin attempt (quorum gate): %v", err) + } + singleAccuserCtx, err := coord.NextAttempt(handle3, singleAccuserBundle, uint(threshold), keyGroupSeed) + if err != nil { + t.Fatalf("NextAttempt (quorum gate): %v", err) + } + if containsMember(singleAccuserCtx.TransientlyParked, flooder) { + t.Fatalf("one overflow accuser is below the f+1 quorum (%d); flooder %d must NOT be parked; parked=%v", + quorum, flooder, singleAccuserCtx.TransientlyParked) + } + if !containsMember(singleAccuserCtx.IncludedSet, flooder) { + t.Fatalf("un-parked flooder %d must remain in the included set %v", flooder, singleAccuserCtx.IncludedSet) + } + t.Logf("quorum gate holds: one accuser (< f+1=%d) did not park flooder %d", quorum, flooder) +} diff --git a/pkg/frost/signing/roast_runner_real_cgo_share_conflict_equivocation_frost_native_test.go b/pkg/frost/signing/roast_runner_real_cgo_share_conflict_equivocation_frost_native_test.go new file mode 100644 index 0000000000..98cc9c23f0 --- /dev/null +++ b/pkg/frost/signing/roast_runner_real_cgo_share_conflict_equivocation_frost_native_test.go @@ -0,0 +1,439 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "bytes" + "context" + "errors" + "fmt" + "sort" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + netlocal "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This test closes the third real-crypto-under-failure gap: a Byzantine signer +// that DOUBLE-SIGNS its round-2 share (member equivocation), detected over the +// real bus by an honest Round2Collector as EquivocationKindShareConflict. Prior +// coverage was disjoint - the collector's conflict detection +// (round2_collector.go) was unit-tested with synthetic ShareSubmissions and +// fakes, never over a genuine engine-produced FROST share submitted through the +// real transport. +// +// Flow (a companion to the dropout/invalid-share tests, which force a retry; +// this one exercises the BLAME-EVIDENCE path - a share conflict is recorded as +// self-incriminating proof for the f+1 adjudication of Phase 7.2b-4, it does not +// abort the live attempt): +// +// attempt 1 (REAL crypto): a selected signer runs round 2 and produces a +// genuine FROST signature share, then broadcasts TWO body-different signed +// share submissions for the SAME attempt - its real share and a re-signed copy +// with a mutated signature_share. Both name the elected coordinator and bind +// the authoritative signing package, so both are ACCEPTED aggregation shares +// (not divergent evidence): a second accepted-but-different signed body is +// member double-signing. +// DETECTION (REAL collector): an honest Round2Collector fed the coordinator's +// authoritative package and then both shares flags the second as +// EquivocationKindShareConflict and returns ErrShareConflict, and the +// process-wide equivocation observer receives the evidence naming the +// Byzantine submitter. +// +// DETERMINISM. The Byzantine seat genuinely BROADCASTS both shares on the live +// bus (an honest receiver may also detect the conflict - a bonus we do not rely +// on), but the runner's collect-shares loop stops at the threshold and only +// opportunistically drains buffered duplicates, so which collector observes the +// second share over the wire is timing-dependent. To make DETECTION +// deterministic without depending on that race, the test also drives an +// independent honest collector directly with the exact bytes the Byzantine +// broadcast (captured at the point of broadcast) plus the coordinator's +// authoritative package (captured from its broadcast). Those are the real +// on-wire envelopes - the same bytes an honest node receives - so feeding them +// is behaviourally identical to reception, only order-guaranteed. Attempt 1 runs +// only {coordinator, target} (the deterministic co-signer trick from the sibling +// tests) so the target is necessarily the coordinator's sole co-signer and its +// real round-2 share is actually produced. + +// signingPackageCapturingBus wraps a RunnerBus and records a copy of every +// signing-package envelope the wrapped seat broadcasts (the elected +// coordinator's authoritative package), so the test can feed the real, +// coordinator-signed package to an honest auditor collector. All traffic passes +// through unchanged. +type signingPackageCapturingBus struct { + inner RunnerBus + mu *sync.Mutex + pkgs *[][]byte +} + +func (b signingPackageCapturingBus) Broadcast(msg RunnerMessage) { + if msg.Type == RunnerMsgSigningPackage { + b.mu.Lock() + *b.pkgs = append(*b.pkgs, append([]byte(nil), msg.Payload...)) + b.mu.Unlock() + } + b.inner.Broadcast(msg) +} + +func (b signingPackageCapturingBus) Subscribe() *RunnerBusSubscriber { return b.inner.Subscribe() } + +// shareEquivocatingBus wraps a RunnerBus so the wrapped seat DOUBLE-SIGNS its +// round-2 share: when it broadcasts its genuine share submission, the wrapper +// captures that real envelope, derives a body-different conflicting one (same +// attempt/coordinator/package binding, one bit flipped in the signature_share, +// re-signed by the same seat), captures it too, and broadcasts BOTH. Keeping the +// coordinator and signing-package binding identical is what makes the second +// share an ACCEPTED conflict (member double-signing) rather than a divergent +// share. All other traffic passes through untouched. +type shareEquivocatingBus struct { + inner RunnerBus + signer roast.Signer + mu *sync.Mutex + real *[][]byte + conflict *[][]byte + buildErr *[]error +} + +func (b shareEquivocatingBus) Broadcast(msg RunnerMessage) { + if msg.Type != RunnerMsgShareSubmission { + b.inner.Broadcast(msg) + return + } + realEnvelope := append([]byte(nil), msg.Payload...) + conflictEnvelope, err := buildConflictingShareEnvelope(realEnvelope, b.signer) + + b.mu.Lock() + *b.real = append(*b.real, realEnvelope) + if err != nil { + *b.buildErr = append(*b.buildErr, err) + } else { + *b.conflict = append(*b.conflict, conflictEnvelope) + } + b.mu.Unlock() + + // The real share first, then the body-different double-sign. Both are + // delivered by the bus (it never dedups body-different messages from a + // sender - that suppression would destroy the very equivocation evidence). + b.inner.Broadcast(msg) + if err == nil { + b.inner.Broadcast(RunnerMessage{ + Type: RunnerMsgShareSubmission, + Sender: msg.Sender, + Attempt: msg.Attempt, + Payload: conflictEnvelope, + }) + } +} + +func (b shareEquivocatingBus) Subscribe() *RunnerBusSubscriber { return b.inner.Subscribe() } + +// buildConflictingShareEnvelope derives a second, body-different signed share +// submission from a genuine one: it keeps the attempt, submitter, coordinator, +// and signing-package binding (so an honest collector classifies it as an +// ACCEPTED share, eligible for aggregation - the prerequisite for the conflict, +// not a divergent share) and flips one bit of the FROST signature_share so the +// signed BODY differs. It re-signs with the same seat's signer, modelling a +// member that authored two different shares for one instruction. +func buildConflictingShareEnvelope(realEnvelope []byte, signer roast.Signer) ([]byte, error) { + var real roast.ShareSubmission + if err := real.Unmarshal(realEnvelope); err != nil { + return nil, fmt.Errorf("unmarshal real share: %w", err) + } + if len(real.SignatureShare) == 0 { + return nil, errors.New("real share has an empty signature share") + } + conflict := &roast.ShareSubmission{ + AttemptContextHash: append([]byte(nil), real.AttemptContextHash...), + SubmitterIDValue: real.SubmitterIDValue, + CoordinatorIDValue: real.CoordinatorIDValue, + SigningPackageHash: append([]byte(nil), real.SigningPackageHash...), + SignatureShare: append([]byte(nil), real.SignatureShare...), + } + conflict.SignatureShare[len(conflict.SignatureShare)-1] ^= 0x01 + payload, err := conflict.SignableBytes() + if err != nil { + return nil, err + } + sig, err := signer.Sign(payload) + if err != nil { + return nil, err + } + conflict.SubmitterSignature = sig + envelope, err := conflict.Marshal() + if err != nil { + return nil, err + } + return envelope, nil +} + +func TestRealCgoInteractiveSigning_DoubleSignedShareIsDetectedAsEquivocation(t *testing.T) { + setupRealCgoSignerState(t) + + engine := &buildTaggedTBTCSignerEngine{} + sessionID := fmt.Sprintf("real-cgo-share-conflict-%d", realCgoSessionSeq.Add(1)) + + const n = 3 + const threshold uint16 = 2 + participantIDs := []byte{1, 2, 3} + included := []group.MemberIndex{1, 2, 3} + + keyGroup := runRealCgoDKGKeyGroup(t, engine, sessionID, participantIDs, threshold) + keyGroupSeed := []byte(keyGroup) + + var messageDigest [attempt.MessageDigestLength]byte + for i := range messageDigest { + messageDigest[i] = 0x42 + } + + attempt1Ctx, err := attempt.NewAttemptContext( + sessionID, keyGroup, keyGroupSeed, messageDigest, 0, included, nil, + ) + if err != nil { + t.Fatalf("attempt 1 context: %v", err) + } + + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + logger := &testutils.MockLogger{} + + // Capture the equivocation evidence the collector emits process-wide. Only a + // single observer is supported; clear any stale registration first so serial + // tests in this package do not collide, and unregister on exit. + var evMu sync.Mutex + var conflictEvents []roast.EquivocationEvidence + roast.UnregisterEquivocationEvidenceObserver() + if err := roast.RegisterEquivocationEvidenceObserver(func(ev roast.EquivocationEvidence) { + evMu.Lock() + defer evMu.Unlock() + conflictEvents = append(conflictEvents, ev) + }); err != nil { + t.Fatalf("register equivocation observer: %v", err) + } + defer roast.UnregisterEquivocationEvidenceObserver() + + // Resolve attempt 1's elected coordinator from a probe binding, so the + // non-coordinator target is necessarily the coordinator's co-signer. + probeCoord := roast.NewInMemoryCoordinatorWithSigning(1, signer, verifier) + probeHandle, err := probeCoord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("probe begin attempt: %v", err) + } + probeBinding, err := NewActiveRoastAttempt(probeCoord, probeHandle, attempt1Ctx, sessionID, nil, keyGroupSeed) + if err != nil { + t.Fatalf("probe binding: %v", err) + } + coordinator := probeBinding.ElectedCoordinator() + nonCoordinators := make([]group.MemberIndex, 0, n-1) + for _, m := range included { + if m != coordinator { + nonCoordinators = append(nonCoordinators, m) + } + } + sort.Slice(nonCoordinators, func(i, j int) bool { return nonCoordinators[i] < nonCoordinators[j] }) + target := nonCoordinators[0] // double-signs its round-2 share + t.Logf("attempt 1: coordinator=%d target(double-signer)=%d", coordinator, target) + + // Shared operator identities + membership validator over all 3 seats. + chainSigning := local_v1.Connect(n, n).Signing() + publicKeys := make(map[group.MemberIndex]*operator.PublicKey, n) + addresses := make([]chain.Address, 0, n) + for _, m := range included { + _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (seat %d): %v", m, err) + } + publicKeys[m] = publicKey + addresses = append(addresses, chainSigning.PublicKeyBytesToAddress( + operator.MarshalUncompressed(publicKey), + )) + } + validator := group.NewMembershipValidator(&testutils.MockLogger{}, addresses, chainSigning) + + var ( + captureMu sync.Mutex + capturedPackages [][]byte + capturedReal [][]byte + capturedConflict [][]byte + buildErrs []error + ) + + newSeat := func(ctx context.Context, member group.MemberIndex) *dropoutSeat { + channel, err := netlocal.ConnectWithKey(publicKeys[member]). + BroadcastChannelFor("frost-roast-interactive-signing") + if err != nil { + t.Fatalf("broadcast channel (seat %d): %v", member, err) + } + var bus RunnerBus + bus, err = NewBroadcastChannelRunnerBus(ctx, logger, channel, validator) + if err != nil { + t.Fatalf("runner bus (seat %d): %v", member, err) + } + switch member { + case coordinator: + bus = signingPackageCapturingBus{inner: bus, mu: &captureMu, pkgs: &capturedPackages} + case target: + bus = shareEquivocatingBus{ + inner: bus, + signer: signer, + mu: &captureMu, + real: &capturedReal, + conflict: &capturedConflict, + buildErr: &buildErrs, + } + } + coord := roast.NewInMemoryCoordinatorWithSigning(member, signer, verifier) + handle, err := coord.BeginAttempt(attempt1Ctx) + if err != nil { + t.Fatalf("begin attempt (seat %d): %v", member, err) + } + binding, err := NewActiveRoastAttempt(coord, handle, attempt1Ctx, sessionID, nil, keyGroupSeed) + if err != nil { + t.Fatalf("active attempt (seat %d): %v", member, err) + } + collector := roast.NewRound2Collector(verifier) + runner, err := newInteractiveSigningRunner(binding, member, threshold, engine, collector, coord, signer, bus) + if err != nil { + t.Fatalf("runner (seat %d): %v", member, err) + } + return &dropoutSeat{member: member, coord: coord, handle: handle, binding: binding, runner: runner} + } + + // ---- Attempt 1: coordinator + double-signing target. ---- + ctx1, cancel1 := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel1() + + coordSeat := newSeat(ctx1, coordinator) + targetSeat := newSeat(ctx1, target) + coordRes := runSeatAsync(coordSeat, ctx1) + targetRes := runSeatAsync(targetSeat, ctx1) + <-coordRes.done + <-targetRes.done + t.Logf("attempt 1 outcomes: coordinator err=%v sigLen=%d, target err=%v sigLen=%d", + coordRes.err, len(coordRes.sig), targetRes.err, len(targetRes.sig)) + + captureMu.Lock() + packages := append([][]byte{}, capturedPackages...) + realShares := append([][]byte{}, capturedReal...) + conflictShares := append([][]byte{}, capturedConflict...) + buildErrsCopy := append([]error{}, buildErrs...) + captureMu.Unlock() + + if len(buildErrsCopy) != 0 { + t.Fatalf("building the conflicting share failed: %v", buildErrsCopy) + } + + // FAULT REACHED (crypto side): the target actually ran round 2 and produced a + // genuine FROST share, then broadcast it. Without a captured real share, the + // equivocation would be synthetic and the test vacuous. + if len(realShares) == 0 { + t.Fatalf("target %d never broadcast a round-2 share; its equivocation was not reached", target) + } + if len(conflictShares) == 0 { + t.Fatalf("no conflicting share was derived from the target's real share") + } + if len(packages) == 0 { + t.Fatalf("coordinator %d never broadcast an authoritative signing package", coordinator) + } + + realEnvelope := realShares[0] + conflictEnvelope := conflictShares[0] + + // FAULT REACHED (equivocation is genuine): two byte-different signed share + // envelopes, both parseable, both binding the SAME attempt / coordinator / + // authoritative package - so both are ACCEPTED shares and the second is a true + // double-sign, not a divergent share that would be classified differently. + if bytes.Equal(realEnvelope, conflictEnvelope) { + t.Fatalf("the two share envelopes are byte-identical; no equivocation was produced") + } + var realSub, conflictSub roast.ShareSubmission + if err := realSub.Unmarshal(realEnvelope); err != nil { + t.Fatalf("unmarshal real share: %v", err) + } + if err := conflictSub.Unmarshal(conflictEnvelope); err != nil { + t.Fatalf("unmarshal conflicting share: %v", err) + } + if realSub.SubmitterID() != target || conflictSub.SubmitterID() != target { + t.Fatalf("both shares must be submitted by target %d; got real=%d conflict=%d", + target, realSub.SubmitterID(), conflictSub.SubmitterID()) + } + if realSub.CoordinatorID() != coordinator || conflictSub.CoordinatorID() != coordinator { + t.Fatalf("both shares must name coordinator %d; got real=%d conflict=%d", + coordinator, realSub.CoordinatorID(), conflictSub.CoordinatorID()) + } + if !bytes.Equal(realSub.SigningPackageHash, conflictSub.SigningPackageHash) { + t.Fatalf("both shares must bind the same authoritative package (else it is a divergent share, not a conflict)") + } + if bytes.Equal(realSub.SignatureShare, conflictSub.SignatureShare) { + t.Fatalf("the two shares must differ in their signature_share (the double-signed field)") + } + + // ---- Deterministic detection: an honest collector fed the authoritative + // package and both shares must flag the second as a member conflict. ---- + pkg := &roast.SigningPackage{} + if err := pkg.Unmarshal(packages[0]); err != nil { + t.Fatalf("unmarshal authoritative signing package: %v", err) + } + // The shares must answer THIS captured package, or they would be divergent. + pkgBodyHash, err := pkg.BodyHash() + if err != nil { + t.Fatalf("authoritative package body hash: %v", err) + } + if !bytes.Equal(realSub.SigningPackageHash, pkgBodyHash[:]) { + t.Fatalf("the target's shares do not bind the coordinator's authoritative package; they would be divergent, not a conflict") + } + + prevHash := attempt1Ctx.Hash() + auditor := roast.NewRound2Collector(verifier) + if err := auditor.BeginAttempt(prevHash[:], coordinator, included); err != nil { + t.Fatalf("auditor begin attempt: %v", err) + } + if err := auditor.RecordSigningPackage(pkg); err != nil { + t.Fatalf("auditor record authoritative package: %v", err) + } + // The genuine share is accepted; the second, body-different accepted share + // from the same submitter is member double-signing. + if err := auditor.RecordShareSubmission(&realSub); err != nil { + t.Fatalf("auditor must accept the target's genuine share; got: %v", err) + } + conflictErr := auditor.RecordShareSubmission(&conflictSub) + if !errors.Is(conflictErr, roast.ErrShareConflict) { + t.Fatalf("auditor must reject the double-signed share as a conflict (ErrShareConflict); got: %v", conflictErr) + } + + // ---- The equivocation evidence names the culprit. ---- + evMu.Lock() + events := append([]roast.EquivocationEvidence{}, conflictEvents...) + evMu.Unlock() + + var conflictEvidence *roast.EquivocationEvidence + for i := range events { + if events[i].Kind == roast.EquivocationKindShareConflict && events[i].Sender == target { + conflictEvidence = &events[i] + break + } + } + if conflictEvidence == nil { + t.Fatalf("expected a %s equivocation event naming submitter %d; got events=%v", + roast.EquivocationKindShareConflict, target, events) + } + // The evidence must carry the two distinct signed envelopes (the proof + // material an f+1 adjudication compares), not empty placeholders. + if len(conflictEvidence.ExistingEnvelope) == 0 || len(conflictEvidence.ConflictingEnvelope) == 0 { + t.Fatalf("conflict evidence must carry both signed envelopes; got existing=%d conflicting=%d bytes", + len(conflictEvidence.ExistingEnvelope), len(conflictEvidence.ConflictingEnvelope)) + } + if bytes.Equal(conflictEvidence.ExistingEnvelope, conflictEvidence.ConflictingEnvelope) { + t.Fatalf("conflict evidence envelopes must differ (self-incriminating double-sign)") + } + t.Logf("honest collector detected member double-signing: %s from submitter %d (existing=%d conflicting=%d bytes)", + conflictEvidence.Kind, conflictEvidence.Sender, + len(conflictEvidence.ExistingEnvelope), len(conflictEvidence.ConflictingEnvelope)) +} diff --git a/pkg/frost/signing/roast_selection_consume_frost_native_roast_retry_test.go b/pkg/frost/signing/roast_selection_consume_frost_native_roast_retry_test.go new file mode 100644 index 0000000000..e1b7596ffd --- /dev/null +++ b/pkg/frost/signing/roast_selection_consume_frost_native_roast_retry_test.go @@ -0,0 +1,259 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func signedForcedSnapshot( + member group.MemberIndex, + hash [attempt.MessageDigestLength]byte, +) *roast.LocalEvidenceSnapshot { + snap := roast.NewLocalEvidenceSnapshot(member, hash, attempt.Evidence{}) + payload, _ := snap.SignableBytes() + sig, _ := fixedSigner{}.Sign(payload) + snap.OperatorSignature = sig + return snap +} + +func resetSelectionRegistries(t *testing.T) { + t.Helper() + ResetRoastRetryRegistrationForTest() + ResetRoastTransitionRegistryForTest() + ResetObservedAttemptRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + t.Cleanup(ResetObservedAttemptRegistryForTest) +} + +func TestConsumeRoastTransitionForSelection_FallbackInitialAttempt(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + resetSelectionRegistries(t) + + if _, _, err := ConsumeRoastTransitionForSelection("session", 1, 0, 3); !errors.Is( + err, ErrRoastSelectionFallBackToLegacy, + ) { + t.Fatalf("retry 0 must request the legacy fallback, got %v", err) + } +} + +func TestConsumeRoastTransitionForSelection_FallbackInactiveRoast(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + resetSelectionRegistries(t) + + // No coordinator registered -> inactive -> uniform legacy fallback. + if _, _, err := ConsumeRoastTransitionForSelection("session", 1, 1, 3); !errors.Is( + err, ErrRoastSelectionFallBackToLegacy, + ) { + t.Fatalf("inactive ROAST must request the legacy fallback, got %v", err) + } +} + +func TestConsumeRoastTransitionForSelection_FailsClosedNoRecord(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + resetSelectionRegistries(t) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + // Active ROAST, a retry, but no record -> a transition was expected -> fail + // closed (NOT the legacy-fallback sentinel). + _, _, err := ConsumeRoastTransitionForSelection("session", 1, 1, 3) + if err == nil || errors.Is(err, ErrRoastSelectionFallBackToLegacy) { + t.Fatalf("a missing expected transition must fail closed, got %v", err) + } +} + +// TestConsumeRoastTransitionForSelection_PartialRegistrationFailsClosed asserts the +// multi-seat partial-activation fracture guard (Codex P2-1): when ROAST is active +// for the process (one local seat registered) but the SELECTING seat has no +// registered coordinator, selection FAILS CLOSED rather than falling back to legacy +// -- a legacy fallback for the unregistered seat while a sibling selects from the +// transition would split the included set. +func TestConsumeRoastTransitionForSelection_PartialRegistrationFailsClosed(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + resetSelectionRegistries(t) + // One of the operator's seats (member 1) is registered; member 2 is NOT, so ROAST + // retry is active for the process but member 2 has no coordinator. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + _, _, err := ConsumeRoastTransitionForSelection("session", 2, 1, 3) + if err == nil || errors.Is(err, ErrRoastSelectionFallBackToLegacy) { + t.Fatalf("partial registration must fail closed, not fall back to legacy; got %v", err) + } +} + +func TestConsumeRoastTransitionForSelection_FailsClosedStaleRecord(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + resetSelectionRegistries(t) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + // A record fresh for retry 1 (prev attempt 0) but we select for retry 3. + prevCtx := newExchangeTestContext(t, "session", []group.MemberIndex{1, 2, 3}, []byte{0x01}) + RecordRoastTransition("session", 1, RoastTransitionRecord{ + Bundle: &roast.TransitionMessage{CoordinatorIDValue: 1}, + PreviousContext: prevCtx, + }) + + _, _, err := ConsumeRoastTransitionForSelection("session", 1, 3, 3) + if err == nil || errors.Is(err, ErrRoastSelectionFallBackToLegacy) { + t.Fatalf("a stale record must fail closed, got %v", err) + } +} + +func TestConsumeRoastTransitionForSelection_CarriesParking(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + resetSelectionRegistries(t) + + roastSessionID := "consume-parking-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x0c} + prevCtx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := prevCtx.Hash() + + probe := roast.NewInMemoryCoordinatorWithSigning(0, fixedSigner{}, roast.NoOpSignatureVerifier()) + probeHandle, _ := probe.BeginAttempt(prevCtx) + elected, _ := probe.SelectedCoordinator(probeHandle) + + // Pick a parked member that is NOT the elected coordinator, so the coordinator + // is in the bundle and can aggregate; omit its forced snapshot so NextAttempt + // silence-parks it (absent-from-bundle -> transient park). + var parkedMember group.MemberIndex + for _, m := range included { + if m != elected { + parkedMember = m + break + } + } + + coord := roast.NewInMemoryCoordinatorWithSigning( + elected, fixedSigner{}, roast.NoOpSignatureVerifier(), + ) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: coord, + Signer: fixedSigner{}, + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(elected), + }) + handle, _ := coord.BeginAttempt(prevCtx) + for _, m := range included { + if m == parkedMember { + continue // omit -> silence-parked by NextAttempt + } + if err := coord.RecordEvidence(handle, signedForcedSnapshot(m, hash)); err != nil { + t.Fatalf("record evidence for member %d: %v", m, err) + } + } + bundle, err := coord.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate bundle: %v", err) + } + RecordRoastTransition(roastSessionID, elected, RoastTransitionRecord{ + Bundle: bundle, + PreviousHandle: handle, + PreviousContext: prevCtx, + DkgGroupPublicKey: dkgKey, + }) + + // threshold 2 so the 2-member next set stays feasible. + includedSet, parked, err := ConsumeRoastTransitionForSelection(roastSessionID, elected, 1, 2) + if err != nil { + t.Fatalf("consume must succeed: %v", err) + } + // The absent member is carried as parked (reinstated next attempt, not + // permanently excluded) and is not in the included set. + if len(parked) != 1 || parked[0] != parkedMember { + t.Fatalf("expected parked [%d], got %v", parkedMember, parked) + } + for _, m := range includedSet { + if m == parkedMember { + t.Fatalf("parked member %d must not be in the included set %v", parkedMember, includedSet) + } + } +} + +func TestConsumeRoastTransitionForSelection_ConsumesFreshRecord(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + resetSelectionRegistries(t) + + roastSessionID := "consume-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x0a, 0x0b} + prevCtx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := prevCtx.Hash() + + // Determine the deterministically elected coordinator for prevCtx. + probe := roast.NewInMemoryCoordinatorWithSigning(0, fixedSigner{}, roast.NoOpSignatureVerifier()) + probeHandle, err := probe.BeginAttempt(prevCtx) + if err != nil { + t.Fatalf("probe begin attempt: %v", err) + } + elected, err := probe.SelectedCoordinator(probeHandle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + + // Register a coordinator bound to the elected member and build a real bundle. + coord := roast.NewInMemoryCoordinatorWithSigning( + elected, fixedSigner{}, roast.NoOpSignatureVerifier(), + ) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: coord, + Signer: fixedSigner{}, + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(elected), + }) + handle, err := coord.BeginAttempt(prevCtx) + if err != nil { + t.Fatalf("begin attempt: %v", err) + } + for _, m := range included { + if err := coord.RecordEvidence(handle, signedForcedSnapshot(m, hash)); err != nil { + t.Fatalf("record evidence for member %d: %v", m, err) + } + } + bundle, err := coord.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate bundle: %v", err) + } + RecordRoastTransition(roastSessionID, elected, RoastTransitionRecord{ + Bundle: bundle, + PreviousHandle: handle, + PreviousContext: prevCtx, + DkgGroupPublicKey: dkgKey, + }) + + // Consume for roast attempt 1 (prevCtx.AttemptNumber 0 + 1 == 1). + includedSet, parked, err := ConsumeRoastTransitionForSelection(roastSessionID, elected, 1, 3) + if err != nil { + t.Fatalf("consume must succeed for a fresh record: %v", err) + } + // Every included member submitted a proof-of-attendance snapshot, so none is + // silence-parked: the next included set equals the prior included set and the + // parked set is empty. + if len(includedSet) != len(included) { + t.Fatalf("expected the full included set %v, got %v", included, includedSet) + } + if len(parked) != 0 { + t.Fatalf("expected no parked members, got %v", parked) + } +} diff --git a/pkg/frost/signing/roast_selection_consume_frost_roast_retry.go b/pkg/frost/signing/roast_selection_consume_frost_roast_retry.go new file mode 100644 index 0000000000..1f3e939a8b --- /dev/null +++ b/pkg/frost/signing/roast_selection_consume_frost_roast_retry.go @@ -0,0 +1,137 @@ +//go:build frost_roast_retry + +package signing + +import ( + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ErrRoastSelectionFallBackToLegacy signals that ROAST-driven participant +// selection does not apply to this attempt and the caller must use the legacy +// retry selection. It is a UNIFORM, deterministic decision every honest node +// makes identically -- the initial attempt, or ROAST retry not active -- so a +// legacy fallback on this sentinel never fractures the group. It is NOT a +// fail-closed condition. +var ErrRoastSelectionFallBackToLegacy = errors.New( + "roast selection: not applicable; use legacy selection", +) + +// ConsumeRoastTransitionForSelection computes the next attempt's included set +// from this seat's stored transition record (RFC-21 Phase 7.3 PR2b-1b C3 -- the +// activation of the cross-attempt ROAST retry path). +// +// Returns one of: +// - (includedSet, parked, nil): a FRESH transition record drove the next +// attempt; the caller uses includedSet verbatim (member-level) AND carries +// parked so the attempt after reinstates the parked members. +// - (nil, nil, ErrRoastSelectionFallBackToLegacy): the initial ROAST attempt, +// or ROAST retry is not active -- a UNIFORM legacy fallback (deterministic +// across honest nodes, so non-fracturing). +// - (nil, nil, any other error): a committed ROAST attempt EXPECTED a +// transition but no FRESH record exists, or NextAttempt failed. The caller +// MUST FAIL CLOSED (terminate the retry loop), NEVER fall back to legacy: a +// node that selected legacy while peers selected from the transition would +// split the signing group into divergent included sets -- the fracture class. +// +// The "a transition is expected" predicate is deterministic group-wide: every +// honest node, with the same (uniformly deployed) gating, agrees that +// roastAttemptNumber > 0 under active ROAST retry expects a transition. VerifyBundle +// is NOT called here -- the transition listener already verified the bundle before +// storing the record, so the selector consumes only verified records. +// +// threshold is the FROST signing threshold t for the key group, used by +// NextAttempt's infeasibility check (the next included set must stay at or above +// t). For tBTC wallets the group's honest threshold IS that signing threshold, +// so the caller passes it; if the two ever diverge for a key group, the +// authoritative value is the persisted DKGThreshold. +// roastAttemptNumber is the 0-based COMMITTED ROAST attempt index (advanced only +// by observed attempts, decoupled from the block-paced loop attempt counter), so +// skipped loop iterations do not break the consecutive-transition chain. It +// returns the next attempt's included set AND its transiently-parked set; the +// caller MUST carry the parking, or a one-attempt park becomes permanent. +func ConsumeRoastTransitionForSelection( + roastSessionID string, + member group.MemberIndex, + roastAttemptNumber uint, + threshold uint, +) (included []group.MemberIndex, transientlyParked []group.MemberIndex, err error) { + // The initial ROAST attempt has no prior transition: uniform legacy/initial + // selection. + if roastAttemptNumber == 0 { + return nil, nil, ErrRoastSelectionFallBackToLegacy + } + + // The legacy-fallback decision is PROCESS-level (group-uniform), NOT per-member: + // readiness opted out, NO coordinator registered ANYWHERE in this process, or no + // transition producer built in (frost_roast_retry && !frost_native) -> a uniform + // legacy fallback every honest node makes identically. It must NOT be + // RoastRetryActiveForMember here: a multi-seat operator with member A registered + // and member B not would otherwise drive A via the transition and B via the + // legacy shuffle for the SAME attempt -> divergent included sets (fracture). The + // fallback sentinel is only safe when uniform (RFC-21 Phase 7.3 PR2b-1.5, Codex + // P2-1). + if !RoastRetryActive() { + return nil, nil, ErrRoastSelectionFallBackToLegacy + } + // ROAST retry is active for the process, so THIS seat must have its own + // registered coordinator. A missing one is partial registration (a wiring bug) + // and FAILS CLOSED -- never legacy: falling back to legacy here while the + // registered sibling seats select from the transition would split the included + // set (the fracture class the sentinel must not enable). + deps, ok := RegisteredRoastRetryCoordinatorForMember(member) + if !ok || deps.Coordinator == nil { + return nil, nil, fmt.Errorf( + "roast selection: seat %d has no registered coordinator under active ROAST retry; fail closed", + member, + ) + } + + // From here a transition from the prior COMMITTED attempt IS expected; its + // absence is fail-closed, never a legacy fallback. + record, ok := RoastTransitionForSession(roastSessionID, member) + if !ok { + return nil, nil, fmt.Errorf( + "roast selection: no transition record for roast attempt %d; fail closed", + roastAttemptNumber, + ) + } + + // Freshness: the record must describe the IMMEDIATELY prior committed attempt. + // The previous attempt's 0-based AttemptNumber plus one must equal this + // roast attempt number. A stale record (e.g. a missed intervening transition) + // must not drive selection -- fail closed. + if uint(record.PreviousContext.AttemptNumber)+1 != roastAttemptNumber { + return nil, nil, fmt.Errorf( + "roast selection: stale transition record (prev attempt %d, expected %d); fail closed", + record.PreviousContext.AttemptNumber, roastAttemptNumber-1, + ) + } + + nextContext, nextErr := deps.Coordinator.NextAttempt( + record.PreviousHandle, + record.Bundle, + threshold, + record.DkgGroupPublicKey, + ) + if nextErr != nil { + // Includes ErrAttemptInfeasible (the next included set would drop below + // threshold): the session cannot make progress -- fail closed. + return nil, nil, fmt.Errorf("roast selection: next attempt: %w", nextErr) + } + + // Defensive: the derived attempt number must match the roast attempt we select + // for (NextAttempt derives prev+1, which equals roastAttemptNumber given the + // freshness check above; re-assert so any drift fails closed rather than + // mis-selects). + if uint(nextContext.AttemptNumber) != roastAttemptNumber { + return nil, nil, fmt.Errorf( + "roast selection: derived attempt %d does not match roast attempt %d; fail closed", + nextContext.AttemptNumber, roastAttemptNumber, + ) + } + + return nextContext.IncludedSet, nextContext.TransientlyParked, nil +} diff --git a/pkg/frost/signing/roast_selection_consume_no_producer_frost_roast_retry_test.go b/pkg/frost/signing/roast_selection_consume_no_producer_frost_roast_retry_test.go new file mode 100644 index 0000000000..9acaf61409 --- /dev/null +++ b/pkg/frost/signing/roast_selection_consume_no_producer_frost_roast_retry_test.go @@ -0,0 +1,41 @@ +//go:build frost_roast_retry && !frost_native + +package signing + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" +) + +// TestConsumeRoastTransitionForSelection_FallbackWhenNoProducer asserts that in a +// frost_roast_retry build WITHOUT frost_native -- where the participant selector +// and the coordinator registry exist but nothing PRODUCES transition records (the +// observe step, exchange, and aggregation are frost_native && frost_roast_retry) -- +// a retry falls back to the uniform legacy shuffle instead of fail-closing against +// a transition record that can never be created (Codex P2-1). Without the producer +// gate in RoastRetryActive this would fail closed and stall the signing. +func TestConsumeRoastTransitionForSelection_FallbackWhenNoProducer(t *testing.T) { + if roastTransitionProducerAvailable() { + t.Fatal("precondition: this build (no frost_native) must have no producer") + } + + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + // roastAttemptNumber 1 (> 0): with a producer this expects a transition and + // fail-closes when none exists; without a producer it must fall back to legacy + // (the deterministic, group-wide outcome every no-producer node reaches). + _, _, err := ConsumeRoastTransitionForSelection("session", 1, 1, 3) + if !errors.Is(err, ErrRoastSelectionFallBackToLegacy) { + t.Fatalf("a no-producer build must fall back to legacy, not fail closed; got %v", err) + } +} diff --git a/pkg/frost/signing/roast_shapeb_libp2p_multiproc_e2e_frost_native_test.go b/pkg/frost/signing/roast_shapeb_libp2p_multiproc_e2e_frost_native_test.go new file mode 100644 index 0000000000..4e92cb07cd --- /dev/null +++ b/pkg/frost/signing/roast_shapeb_libp2p_multiproc_e2e_frost_native_test.go @@ -0,0 +1,673 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + stdnet "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/firewall" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + keepnet "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/net/libp2p" + "github.com/keep-network/keep-core/pkg/net/retransmission" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This file is the "shape (B)" real-crypto SEPARATE-PROCESS e2e (the RFC-21 Phase 7.3 +// fidelity step that the in-process shape-(A) harness explicitly deferred). Where shape +// (A) runs n runners against ONE process-global engine over the in-process pkg/net/local +// bus - so only the FIRST seat to aggregate wins and the rest observe +// interactive_attempt_already_aggregated - shape (B) launches n SEPARATE OS PROCESSES, +// each with its OWN engine and its OWN encrypted state dir, talking over REAL libp2p +// (gossipsub + the production protobuf/pubsub outer framing, not the local bus). The +// added coverage is exactly the three things shape (A)'s own comment lists as out of +// scope: per-node engine/state isolation, per-process linking/env, and the libp2p outer +// framing - and crucially, because every process has its own engine, EVERY node +// aggregates the BIP-340 signature independently (n winners, not one). +// +// KEY MATERIAL: the engine's DKG is a single centralized FFI call +// (frost_tbtc_run_dkg), so this harness runs DKG ONCE in the orchestrator, which +// persists the encrypted key packages to a bootstrap state file (fixed dev state- +// encryption key), then copies that file into each worker's isolated state dir. Each +// worker's fresh engine loads it and opens the interactive session for its own member. +// (A worker therefore physically holds the whole key group, not just its own share - +// the one fidelity gap vs. an interactive DKG; it is a key-CUSTODY property, not a +// transport/aggregation one, and is orthogonal to what this harness proves. True +// single-share custody needs an interactive DKG or a per-member key-package export FFI, +// neither of which the engine exposes today.) +// +// MECHANISM: the orchestrator re-execs THIS test binary (which is already linked against +// libfrost_tbtc) as each worker via -test.run + the FROST_SHAPEB_WORKER env, so the +// workers inherit the cgo linking with no separate build. Each worker prints +// SHAPEB_SIGNATURE= to stdout; the orchestrator asserts all n match and are valid +// 64-byte signatures. + +const ( + shapeBWorkerEnv = "FROST_SHAPEB_WORKER" + shapeBBootstrapEnv = "FROST_SHAPEB_BOOTSTRAP" + shapeBConfigEnv = "FROST_SHAPEB_CONFIG" + shapeBBootstrapSess = "FROST_SHAPEB_BS_SESSION" + shapeBBootstrapN = "FROST_SHAPEB_BS_N" + shapeBBootstrapThr = "FROST_SHAPEB_BS_THRESHOLD" + shapeBTopic = "frost-roast-interactive-signing" + shapeBSigPrefix = "SHAPEB_SIGNATURE=" + shapeBErrPrefix = "SHAPEB_ERROR=" + shapeBKeyGroupPrefix = "SHAPEB_KEYGROUP=" + shapeBSkipPrefix = "SHAPEB_SKIP=" +) + +type shapeBMember struct { + Index int `json:"index"` + OperatorDHex string `json:"operator_d_hex"` + Port int `json:"port"` + Multiaddr string `json:"multiaddr"` + StatePath string `json:"state_path"` +} + +type shapeBConfig struct { + N int `json:"n"` + Threshold int `json:"threshold"` + SessionID string `json:"session_id"` + KeyGroup string `json:"key_group"` + MessageHex string `json:"message_hex"` + Topic string `json:"topic"` + ReadyDir string `json:"ready_dir"` + Members []shapeBMember `json:"members"` +} + +// TestRealCgoInteractiveSigning_Libp2pMultiProc_ShapeB is BOTH the orchestrator and (when +// re-exec'd with FROST_SHAPEB_WORKER set) the per-node worker. The worker branch runs one +// seat to a real signature over real libp2p; the orchestrator branch wires the group, +// launches the workers, and asserts every one independently aggregates the same signature. +func TestRealCgoInteractiveSigning_Libp2pMultiProc_ShapeB(t *testing.T) { + if os.Getenv(shapeBBootstrapEnv) != "" { + runShapeBBootstrap(t) + return + } + if idxStr := os.Getenv(shapeBWorkerEnv); idxStr != "" { + runShapeBWorker(t, idxStr) + return + } + runShapeBOrchestrator(t, 3, 2) +} + +// runShapeBBootstrap runs the centralized DKG in its OWN process so the orchestrator +// process never binds the process-global engine (the engine binds its state lock to the +// first path it sees and refuses to switch - which would otherwise collide with the +// in-process shape-(A) tests that run earlier in the same `go test` binary). It persists +// the key group to TBTC_SIGNER_STATE_PATH (set by the parent) and prints the key group. +func runShapeBBootstrap(t *testing.T) { + n, err := strconv.Atoi(os.Getenv(shapeBBootstrapN)) + if err != nil { + fmt.Printf("%sbad bootstrap n: %v\n", shapeBErrPrefix, err) + return + } + threshold, err := strconv.Atoi(os.Getenv(shapeBBootstrapThr)) + if err != nil { + fmt.Printf("%sbad bootstrap threshold: %v\n", shapeBErrPrefix, err) + return + } + sessionID := os.Getenv(shapeBBootstrapSess) + participantIDs := make([]byte, 0, n) + for i := 1; i <= n; i++ { + participantIDs = append(participantIDs, byte(i)) + } + keyGroup := runRealCgoDKGKeyGroup(t, &buildTaggedTBTCSignerEngine{}, sessionID, participantIDs, uint16(threshold)) + fmt.Printf("%s%s\n", shapeBKeyGroupPrefix, keyGroup) +} + +func runShapeBOrchestrator(t *testing.T, n int, threshold uint16) { + // The SAME fixed dev state-encryption key the in-process harness uses, so the + // encrypted DKG file the bootstrap process writes can be decrypted by every worker. + stateKey := make([]byte, 32) + for i := range stateKey { + stateKey[i] = byte(i + 1) + } + stateKeyHex := hex.EncodeToString(stateKey) + + bootstrapDir := t.TempDir() + bootstrapState := filepath.Join(bootstrapDir, "signer-state") + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) + defer cancel() + + sessionID := fmt.Sprintf("shapeb-libp2p-%d", realCgoSessionSeq.Add(1)) + + // 1. Centralized DKG in a SEPARATE bootstrap process. This keeps the engine out of + // THIS orchestrator process, which shares the binary - hence the process-global + // engine and its bound state lock - with the in-process shape-(A) tests that run + // earlier under the same `-run TestRealCgoInteractiveSigning` invocation. + bootstrapCmd := exec.CommandContext(ctx, os.Args[0], + "-test.run", "^TestRealCgoInteractiveSigning_Libp2pMultiProc_ShapeB$", + "-test.timeout=60s", + ) + bootstrapCmd.Env = withEnvOverrides(os.Environ(), map[string]string{ + shapeBBootstrapEnv: "1", + shapeBBootstrapSess: sessionID, + shapeBBootstrapN: strconv.Itoa(n), + shapeBBootstrapThr: strconv.Itoa(int(threshold)), + "TBTC_SIGNER_PROFILE": "development", + "TBTC_SIGNER_ENFORCE_PROVENANCE_GATE": "false", + "TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX": stateKeyHex, + "TBTC_SIGNER_STATE_PATH": bootstrapState, + frostSubprocessSkipPrefixEnv: shapeBSkipPrefix, + }) + bootstrapOut, err := bootstrapCmd.CombinedOutput() + if skip := extractPrefixed(string(bootstrapOut), shapeBSkipPrefix); skip != "" { + t.Skip(skip) + } + keyGroup := extractPrefixed(string(bootstrapOut), shapeBKeyGroupPrefix) + if keyGroup == "" { + t.Fatalf("bootstrap DKG emitted no key group (err=%v):\n%s", err, indentTail(string(bootstrapOut), 40)) + } + if fi, statErr := os.Stat(bootstrapState); statErr != nil || fi.Size() == 0 { + t.Fatalf("bootstrap DKG did not persist a non-empty state at %s (err=%v)", bootstrapState, statErr) + } + + // 2. One transport operator key per member, free port, and the libp2p peer id (so the + // peer table - hence every worker's bootstrap Peers list - is known before launch). + derivation, err := libp2p.Connect( + ctx, + libp2p.Config{Port: freeTCPPort(t)}, + mustGenOperatorKey(t), + firewall.Disabled, + idleRetransmissionTicker(), + ) + if err != nil { + t.Fatalf("derivation provider: %v", err) + } + + members := make([]shapeBMember, 0, n) + for i := 0; i < n; i++ { + priv, pub, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("operator key (member %d): %v", i+1, err) + } + peerID, err := derivation.CreateTransportIdentifier(pub) + if err != nil { + t.Fatalf("peer id (member %d): %v", i+1, err) + } + port := freeTCPPort(t) + stateDir := filepath.Join(t.TempDir(), fmt.Sprintf("member-%d", i+1)) + if err := copyStateDir(bootstrapDir, stateDir); err != nil { + t.Fatalf("copy state for member %d: %v", i+1, err) + } + members = append(members, shapeBMember{ + Index: i + 1, + OperatorDHex: hex.EncodeToString(priv.D.Bytes()), + Port: port, + Multiaddr: fmt.Sprintf("/ip4/127.0.0.1/tcp/%d/p2p/%s", port, peerID), + StatePath: filepath.Join(stateDir, "signer-state"), + }) + } + + messageDigest := make([]byte, attempt.MessageDigestLength) + for i := range messageDigest { + messageDigest[i] = 0x42 + } + cfg := shapeBConfig{ + N: n, + Threshold: int(threshold), + SessionID: sessionID, + KeyGroup: keyGroup, + MessageHex: hex.EncodeToString(messageDigest), + Topic: shapeBTopic, + ReadyDir: filepath.Join(t.TempDir(), "shapeb-ready"), + Members: members, + } + if err := os.MkdirAll(cfg.ReadyDir, 0o700); err != nil { + t.Fatalf("create readiness dir: %v", err) + } + configPath := filepath.Join(t.TempDir(), "shapeb-config.json") + cfgBytes, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := os.WriteFile(configPath, cfgBytes, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + // 3. Launch n worker processes (this test binary, re-exec'd) and collect their output. + type result struct { + index int + output string + err error + } + results := make([]result, n) + var wg sync.WaitGroup + for i := range members { + wg.Add(1) + go func(idx int, m shapeBMember) { + defer wg.Done() + cmd := exec.CommandContext(ctx, os.Args[0], + "-test.run", "^TestRealCgoInteractiveSigning_Libp2pMultiProc_ShapeB$", + "-test.v", "-test.timeout=140s", + ) + cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ + shapeBWorkerEnv: strconv.Itoa(m.Index), + shapeBConfigEnv: configPath, + "TBTC_SIGNER_PROFILE": "development", + "TBTC_SIGNER_ENFORCE_PROVENANCE_GATE": "false", + "TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX": stateKeyHex, + "TBTC_SIGNER_STATE_PATH": m.StatePath, + frostSubprocessSkipPrefixEnv: shapeBSkipPrefix, + }) + out, err := cmd.CombinedOutput() + results[idx] = result{index: m.Index, output: string(out), err: err} + }(i, members[i]) + } + wg.Wait() + + // 4. Every worker must independently produce the same valid 64-byte signature. + var winning string + winners := 0 + for _, r := range results { + if skip := extractPrefixed(r.output, shapeBSkipPrefix); skip != "" { + t.Skipf("member %d skipped: %s", r.index, skip) + } + sig := extractPrefixed(r.output, shapeBSigPrefix) + if sig == "" { + t.Fatalf("member %d did not emit a signature (err=%v):\n%s", r.index, r.err, indentTail(r.output, 40)) + } + raw, err := hex.DecodeString(sig) + if err != nil || len(raw) != 64 { + t.Fatalf("member %d emitted a bad signature %q (decErr=%v len=%d)", r.index, sig, err, len(raw)) + } + if winning == "" { + winning = sig + } else if sig != winning { + t.Fatalf("member %d produced a different signature than a peer:\n got %s\n want %s", r.index, sig, winning) + } + winners++ + } + if winners != n { + t.Fatalf("expected all %d separate-process nodes to aggregate the signature, got %d", n, winners) + } + t.Logf("shape-B: %d separate-process nodes over real libp2p each aggregated the same BIP-340 signature %s…", n, winning[:16]) +} + +func runShapeBWorker(t *testing.T, idxStr string) { + index, err := strconv.Atoi(idxStr) + if err != nil { + fmt.Printf("%sbad worker index %q: %v\n", shapeBErrPrefix, idxStr, err) + return + } + cfgBytes, err := os.ReadFile(os.Getenv(shapeBConfigEnv)) + if err != nil { + fmt.Printf("%sread config: %v\n", shapeBErrPrefix, err) + return + } + var cfg shapeBConfig + if err := json.Unmarshal(cfgBytes, &cfg); err != nil { + fmt.Printf("%sparse config: %v\n", shapeBErrPrefix, err) + return + } + + var self shapeBMember + peers := make([]string, 0, cfg.N-1) + for _, m := range cfg.Members { + if m.Index == index { + self = m + } else { + peers = append(peers, m.Multiaddr) + } + } + if self.Index == 0 { + fmt.Printf("%smember %d not found in config\n", shapeBErrPrefix, index) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 130*time.Second) + defer cancel() + + selfKey, err := operatorKeyFromDHex(self.OperatorDHex) + if err != nil { + fmt.Printf("%sreconstruct self key: %v\n", shapeBErrPrefix, err) + return + } + + // Real libp2p host: listen on the assigned port, bootstrap to the other members. + provider, err := libp2p.Connect( + ctx, + libp2p.Config{Port: self.Port, Peers: peers, Bootstrap: true}, + selfKey, + firewall.Disabled, + periodicRetransmissionTicker(ctx, 300*time.Millisecond), + ) + if err != nil { + fmt.Printf("%slibp2p connect: %v\n", shapeBErrPrefix, err) + return + } + if err := waitForPeers(ctx, provider, cfg.N-1, 60*time.Second); err != nil { + fmt.Printf("%swait for peers: %v\n", shapeBErrPrefix, err) + return + } + + channel, err := provider.BroadcastChannelFor(cfg.Topic) + if err != nil { + fmt.Printf("%sbroadcast channel: %v\n", shapeBErrPrefix, err) + return + } + + // Membership: map every member index -> its operator address so the bus authenticates + // each broadcast's claimed seat against the authenticated libp2p sender key. + chainSigning := local_v1.Connect(cfg.N, cfg.N).Signing() + addresses := make([]chain.Address, cfg.N) + for _, m := range cfg.Members { + key, err := operatorKeyFromDHex(m.OperatorDHex) + if err != nil { + fmt.Printf("%sreconstruct member %d key: %v\n", shapeBErrPrefix, m.Index, err) + return + } + addresses[m.Index-1] = chainSigning.PublicKeyBytesToAddress( + operator.MarshalUncompressed(&key.PublicKey), + ) + } + logger := &testutils.MockLogger{} + validator := group.NewMembershipValidator(logger, addresses, chainSigning) + + included := make([]group.MemberIndex, 0, cfg.N) + for i := 1; i <= cfg.N; i++ { + included = append(included, group.MemberIndex(i)) + } + keyGroupSeed := []byte(cfg.KeyGroup) + msgBytes, err := hex.DecodeString(cfg.MessageHex) + if err != nil || len(msgBytes) != attempt.MessageDigestLength { + fmt.Printf("%sbad message digest (len=%d err=%v)\n", shapeBErrPrefix, len(msgBytes), err) + return + } + var messageDigest [attempt.MessageDigestLength]byte + copy(messageDigest[:], msgBytes) + + attemptCtx, err := attempt.NewAttemptContext( + cfg.SessionID, cfg.KeyGroup, keyGroupSeed, messageDigest, 0, included, nil, + ) + if err != nil { + fmt.Printf("%sattempt context: %v\n", shapeBErrPrefix, err) + return + } + + member := group.MemberIndex(self.Index) + signer := fixedTestSigner{} + verifier := roast.NoOpSignatureVerifier() + + bus, err := NewBroadcastChannelRunnerBus(ctx, logger, channel, validator) + if err != nil { + fmt.Printf("%srunner bus: %v\n", shapeBErrPrefix, err) + return + } + coord := roast.NewInMemoryCoordinatorWithSigning(member, signer, verifier) + handle, err := coord.BeginAttempt(attemptCtx) + if err != nil { + fmt.Printf("%sbegin attempt: %v\n", shapeBErrPrefix, err) + return + } + ara, err := NewActiveRoastAttempt(coord, handle, attemptCtx, cfg.SessionID, nil, keyGroupSeed) + if err != nil { + fmt.Printf("%sactive attempt: %v\n", shapeBErrPrefix, err) + return + } + collector := roast.NewRound2Collector(verifier) + // This worker's OWN engine, loaded from its OWN copied state dir. + engine := &buildTaggedTBTCSignerEngine{} + runner, err := newInteractiveSigningRunner( + ara, member, cfg.Threshold2uint16(), engine, collector, coord, signer, bus, + ) + if err != nil { + if reportFrostSubprocessSkip("interactive signing runner setup", err) { + return + } + fmt.Printf("%srunner: %v\n", shapeBErrPrefix, err) + return + } + + if err := waitForMultiprocReady(ctx, cfg.ReadyDir, "shapeb", index, cfg.N, 30*time.Second); err != nil { + fmt.Printf("%sreadiness barrier: %v\n", shapeBErrPrefix, err) + return + } + // Gossipsub mesh warmup: a connection is not yet a subscribed mesh peer. Wait + // only after every worker has created its channel and runner subscription, so + // the first runner broadcast cannot race a peer that has not subscribed yet. + select { + case <-time.After(3 * time.Second): + case <-ctx.Done(): + fmt.Printf("%scontext done during warmup\n", shapeBErrPrefix) + return + } + + sig, err := runner.Run(ctx) + if err != nil { + if reportFrostSubprocessSkip("interactive signing runner", err) { + return + } + fmt.Printf("%srun: %v\n", shapeBErrPrefix, err) + return + } + if len(sig) != 64 { + fmt.Printf("%sunexpected signature length %d\n", shapeBErrPrefix, len(sig)) + return + } + state, err := coord.State(handle) + if err != nil { + fmt.Printf("%scoordinator state: %v\n", shapeBErrPrefix, err) + return + } + if state != roast.AttemptStateSucceeded { + fmt.Printf("%sdid not reach Succeeded (got %v)\n", shapeBErrPrefix, state) + return + } + fmt.Printf("%s%s\n", shapeBSigPrefix, hex.EncodeToString(sig)) +} + +// Threshold2uint16 narrows the JSON int threshold to the uint16 the runner wants. +func (c shapeBConfig) Threshold2uint16() uint16 { return uint16(c.Threshold) } + +// ---- helpers ---- + +func mustGenOperatorKey(t *testing.T) *operator.PrivateKey { + t.Helper() + priv, _, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatalf("generate operator key: %v", err) + } + return priv +} + +// operatorKeyFromDHex rebuilds an operator private key from the hex of its scalar D, so +// the orchestrator can hand each worker a stable transport identity across the process +// boundary (the worker's libp2p peer id must match the one in the shared peer table). +func operatorKeyFromDHex(dHex string) (*operator.PrivateKey, error) { + dBytes, err := hex.DecodeString(dHex) + if err != nil { + return nil, fmt.Errorf("decode D: %w", err) + } + curve := local_v1.DefaultCurve + x, y := curve.ScalarBaseMult(dBytes) + return &operator.PrivateKey{ + PublicKey: operator.PublicKey{Curve: operator.Secp256k1, X: x, Y: y}, + D: new(big.Int).SetBytes(dBytes), + }, nil +} + +func freeTCPPort(t *testing.T) int { + t.Helper() + l, err := stdnet.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve free port: %v", err) + } + defer l.Close() + return l.Addr().(*stdnet.TCPAddr).Port +} + +// waitForPeers blocks until the provider is connected to at least want peers. +func waitForPeers(ctx context.Context, provider keepnet.Provider, want int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + connected := len(provider.ConnectionManager().ConnectedPeers()) + if connected >= want { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("only %d of %d peers connected before timeout", connected, want) + } + select { + case <-time.After(250 * time.Millisecond): + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func idleRetransmissionTicker() *retransmission.Ticker { + ticks := make(chan uint64) + close(ticks) + return retransmission.NewTicker(ticks) +} + +func periodicRetransmissionTicker(ctx context.Context, interval time.Duration) *retransmission.Ticker { + ticks := make(chan uint64) + go func() { + tk := time.NewTicker(interval) + defer tk.Stop() + var n uint64 + for { + select { + case <-ctx.Done(): + return + case <-tk.C: + n++ + select { + case ticks <- n: + case <-ctx.Done(): + return + } + } + } + }() + return retransmission.NewTicker(ticks) +} + +func waitForMultiprocReady( + ctx context.Context, + readyDir string, + prefix string, + index int, + n int, + timeout time.Duration, +) error { + if readyDir == "" { + return fmt.Errorf("readiness directory is empty") + } + if err := os.MkdirAll(readyDir, 0o700); err != nil { + return err + } + marker := filepath.Join(readyDir, fmt.Sprintf("%s-%d.ready", prefix, index)) + if err := os.WriteFile(marker, []byte("ready\n"), 0o600); err != nil { + return err + } + + deadline := time.Now().Add(timeout) + for { + count := 0 + entries, err := os.ReadDir(readyDir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Type().IsRegular() && + strings.HasPrefix(entry.Name(), prefix+"-") && + strings.HasSuffix(entry.Name(), ".ready") { + count++ + } + } + if count >= n { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("only %d of %d workers became ready before timeout", count, n) + } + select { + case <-time.After(100 * time.Millisecond): + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func withEnvOverrides(base []string, overrides map[string]string) []string { + out := make([]string, 0, len(base)+len(overrides)) + for _, kv := range base { + key := kv + if i := strings.IndexByte(kv, '='); i >= 0 { + key = kv[:i] + } + if _, ok := overrides[key]; !ok { + out = append(out, kv) + } + } + for k, v := range overrides { + out = append(out, k+"="+v) + } + return out +} + +// copyStateDir copies every non-lock file from src into a fresh dst (the engine binds a +// process-global lock to its own state path, so a stale copied lock must not travel). +func copyStateDir(src, dst string) error { + if err := os.MkdirAll(dst, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() || strings.Contains(e.Name(), ".lock") { + continue + } + data, err := os.ReadFile(filepath.Join(src, e.Name())) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dst, e.Name()), data, 0o600); err != nil { + return err + } + } + return nil +} + +func extractPrefixed(output, prefix string) string { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(strings.TrimPrefix(line, prefix)) + } + } + return "" +} + +func indentTail(output string, maxLines int) string { + lines := strings.Split(strings.TrimRight(output, "\n"), "\n") + if len(lines) > maxLines { + lines = lines[len(lines)-maxLines:] + } + return " " + strings.Join(lines, "\n ") +} diff --git a/pkg/frost/signing/roast_share_blame_frost_native.go b/pkg/frost/signing/roast_share_blame_frost_native.go new file mode 100644 index 0000000000..5a0fc7576c --- /dev/null +++ b/pkg/frost/signing/roast_share_blame_frost_native.go @@ -0,0 +1,110 @@ +//go:build frost_native + +package signing + +import ( + "errors" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// stashInteractiveShareBlame is the third blame-layer fault source (RFC-21 Phase +// 7.3, after PR2b-2's 2a coarse evidence + 2b coordinator-equivocation proofs): it +// turns the engine's interactive aggregate share-verification culprits into f+1 +// reject accusations carried in the transition bundle. +// +// When InteractiveAggregate fails because a member submitted a bad FROST signature +// share, the engine names CANDIDATE culprits (pure crypto, no blame). This re-runs +// each candidate's RETAINED operator-signed share through the engine-backed +// Round2ShareVerifier: collector.ClassifyCandidateCulprits applies the frozen Q1 +// boundary -- only an ACCEPTED retained share that re-verifies INVALID is blamed; +// every not-the-member's-fault condition (mis-binding, cross-attempt, wrong root, +// no retained share, divergent share, indeterminate) fails closed -- and the +// resulting reject accusations are stashed so BroadcastForcedSnapshot carries them +// in this seat's snapshot. computeNextAttempt's f+1 reject gate then excludes a +// member that enough honest observers independently re-verified as a bad-share +// submitter. +// +// f+1 (not instant, unlike 2b coordinator equivocation): a member's share is +// self-incriminating only against THE PACKAGE THIS OBSERVER ACCEPTED. A byzantine +// coordinator's targeted split (different packages to different members) could +// otherwise make one honest observer instant-exclude an honest peer; requiring f+1 +// independent observers -- who re-verify identical retained bytes deterministically +// -- to agree closes that hole. +// +// Best-effort and fail-safe: a runErr that is not a share-verification failure, an +// engine without share re-verification, malformed candidates, a verifier-build +// failure, or an empty classification all stash nothing. It layers ON TOP of the +// 2b coordinator-proof stash for the same attempt -- the union pending-evidence +// entry carries both -- so a single failed attempt can publish reject evidence AND +// equivocation proofs. +func stashInteractiveShareBlame( + runErr error, + attemptCtx attempt.AttemptContext, + request *NativeExecutionFFISigningRequest, + collector *roast.Round2Collector, + engine interactiveSigningEngine, +) { + var shareErr *InteractiveAggregateShareVerificationError + if !errors.As(runErr, &shareErr) || len(shareErr.CandidateCulprits) == 0 { + return + } + // The engine-backed share re-verifier is an OPTIONAL capability (interface + // segregation): absent (e.g. a deployment whose engine cannot re-verify shares) + // -> skip share-blame. The 2b coordinator proofs were stashed separately. + verifyEngine, ok := engine.(Round2ShareVerifyingEngine) + if !ok { + return + } + // Convert the engine's wire uint16 candidates to MemberIndex (uint8), dropping 0 + // and any value above the max member index: a malformed candidate must never + // truncate into -- and so falsely blame -- an honest seat. + candidates := make([]group.MemberIndex, 0, len(shareErr.CandidateCulprits)) + for _, c := range shareErr.CandidateCulprits { + if c == 0 || c > uint16(group.MaxMemberIndex) { + continue + } + candidates = append(candidates, group.MemberIndex(c)) + } + if len(candidates) == 0 { + return + } + + attemptHash := attemptCtx.Hash() + // The binding's SessionID is the STABLE engine/ROAST session + // (attemptCtx.SessionID == active.SessionID()), consistent with attemptHash by + // construction (both derive from attemptCtx) -- the verifier's hard + // construction-time contract that keeps it from turning an honest share invalid. + verifier, err := NewEngineRound2ShareVerifier(verifyEngine, Round2ShareVerificationBinding{ + SessionID: attemptCtx.SessionID, + AttemptContextHash: attemptHash, + TaprootMerkleRoot: request.TaprootMerkleRoot, + }) + if err != nil { + return + } + + rejects, err := collector.ClassifyCandidateCulprits(attemptHash[:], candidates, verifier) + if err != nil || len(rejects) == 0 { + return + } + + evidence := attempt.Evidence{ + Rejects: make(map[group.MemberIndex][]attempt.RejectEntry, len(rejects)), + } + for _, re := range rejects { + if re.Count == 0 { + continue + } + evidence.Rejects[re.Sender] = append(evidence.Rejects[re.Sender], attempt.RejectEntry{ + Reason: re.Reason, + Count: re.Count, + }) + } + if len(evidence.Rejects) == 0 { + return + } + stashPendingEvidence(attemptCtx.SessionID, request.MemberIndex, attemptHash, evidence) +} diff --git a/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry.go b/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry.go new file mode 100644 index 0000000000..2f02f2ec12 --- /dev/null +++ b/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry.go @@ -0,0 +1,332 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "bytes" + "context" + "sync/atomic" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// RoastTransitionExchange runs one signer's session-scoped ROAST transition +// exchange over a RunnerBus (RFC-21 Phase 7.3 PR2b-1b C2). It owns the receive +// side (a listener goroutine for the session lifetime) and exposes the produce +// side the block-timed controller drives: +// +// - listen: peers' evidence snapshots are recorded against the elected +// coordinator's local observe handle (only the elected seat collects, for +// aggregation); peers' transition bundles are verified against this seat's +// own observe handle and, when valid, stored as the next-attempt record. +// - BroadcastForcedSnapshot: a participating seat publishes its proof-of- +// attendance snapshot -- carrying the real evidence the coarse receive loop +// stashed for the attempt, if any -- so it appears in the aggregated bundle +// and is not silence-parked by NextAttempt. It records its OWN snapshot before +// broadcasting, so VerifyBundle's censorship check is meaningful. +// - AggregateAndBroadcast: the elected seat aggregates the collected snapshots +// into a coordinator-signed bundle, stores it locally via the same +// verify+store path receivers use, and broadcasts it. A no-op on a seat that +// is not the elected coordinator (AggregateBundle returns ErrNotAggregator). +// +// Every binding lookup resolves the per-attempt observe handle the C1 observe +// step stored, keyed by (roastSessionID, member, attemptContextHash). The +// exchange never trusts an unsigned outer bus field over the signed body: it +// resolves bindings and roles from the snapshot/bundle's own AttemptContextHash. +type RoastTransitionExchange struct { + ctx context.Context + logger log.StandardLogger + bus RunnerBus + deps RoastRetryDeps + roastSessionID string + member group.MemberIndex + sub *RunnerBusSubscriber + // lostSync is set by the listener when a transition bundle arrives for an + // attempt this seat never observed (it skipped a window peers committed). The + // retry loop reads it via the controller from its own goroutine, hence atomic. + lostSync atomic.Bool +} + +// NewRoastTransitionExchange constructs the exchange and starts its listener for +// the lifetime of ctx (cancel ctx -- e.g. at session end -- to stop it). It +// subscribes to the bus before returning so no peer message broadcast after +// construction is missed. +func NewRoastTransitionExchange( + ctx context.Context, + logger log.StandardLogger, + bus RunnerBus, + deps RoastRetryDeps, + roastSessionID string, + member group.MemberIndex, +) *RoastTransitionExchange { + if logger == nil { + logger = log.Logger("keep-frost-roast-transition-exchange") + } + e := &RoastTransitionExchange{ + ctx: ctx, + logger: logger, + bus: bus, + deps: deps, + roastSessionID: roastSessionID, + member: member, + sub: bus.Subscribe(), + } + go e.listen() + return e +} + +func (e *RoastTransitionExchange) listen() { + // On session end (ctx done), drop any observe bindings this seat did not + // consume per-attempt -- e.g. a signing whose attempts all succeeded never + // produced a transition record to clear, so its bindings would otherwise + // linger until the TTL sweep. + defer clearObservedAttemptsForSession(e.roastSessionID, e.member) + // Likewise drop any stashed coarse-path evidence this seat captured but never + // broadcast -- a succeeded attempt never reaches BroadcastForcedSnapshot, which + // is what consumes the stash (RFC-21 Phase 7.3 PR2b-2 step 2). + defer clearPendingEvidenceForSession(e.roastSessionID, e.member) + for { + select { + case <-e.ctx.Done(): + return + case msg := <-e.sub.EvidenceSnapshots(): + e.onSnapshot(msg) + case msg := <-e.sub.TransitionBundles(): + e.onBundle(msg) + } + } +} + +// onSnapshot records a peer's evidence snapshot against this seat's observe +// handle for the attempt, but ONLY when this seat is the attempt's elected +// coordinator -- it is the only seat that aggregates, so other seats need not +// collect. The snapshot's signed AttemptContextHash (not the unsigned bus field) +// resolves the binding, and the authenticated bus sender must match the claimed +// submitter. +func (e *RoastTransitionExchange) onSnapshot(msg RunnerMessage) { + snapshot := &roast.LocalEvidenceSnapshot{} + if err := snapshot.Unmarshal(msg.Payload); err != nil { + return + } + if snapshot.SenderID() != msg.Sender { + // A seat embedding another member's id: drop, do not let it fill that + // member's slot. + return + } + hash := snapshot.AttemptContextHashArray() + binding, ok := observedAttempt(e.roastSessionID, e.member, hash) + if !ok { + return + } + elected, err := e.deps.Coordinator.SelectedCoordinator(binding.handle) + if err != nil || elected != e.member { + // Only the elected coordinator collects snapshots for aggregation. Compare + // against THIS exchange's seat (e.member), not deps.SelfMember: under + // PR2b-1.5 multi-seat, deps is per-seat so the two agree, but e.member is + // the unambiguous seat identity and avoids relying on the deps binding. + return + } + if err := e.deps.Coordinator.RecordEvidence(binding.handle, snapshot); err != nil { + e.logger.Warnf( + "roast transition: record peer snapshot from [%d] failed: [%v]", + msg.Sender, err, + ) + } +} + +// onBundle verifies a received transition bundle against this seat's observe +// handle and, when valid, stores the next-attempt record. The bundle's claimed +// coordinator must equal the authenticated bus sender (hygiene; VerifyBundle then +// authenticates the coordinator signature cryptographically). +func (e *RoastTransitionExchange) onBundle(msg RunnerMessage) { + bundle := &roast.TransitionMessage{} + if err := bundle.Unmarshal(msg.Payload); err != nil { + return + } + if bundle.CoordinatorID() != msg.Sender { + return + } + hash := bundle.AttemptContextHashArray() + if !attemptEverObserved(e.roastSessionID, e.member, hash) { + // A bundle for an attempt this seat NEVER observed: peers committed an + // attempt this seat skipped (its local block schedule moved past the + // announcement window while peers stayed in it), so this seat is behind the + // group's committed ROAST attempt chain. Flag lost sync; the retry loop + // fails closed before its next selection rather than select a divergent + // included set (the fracture class). + // + // The bundle is authenticated (the bus binds msg.Sender to an operator seat + // and the claimed coordinator must equal it) but UNVERIFIABLE here: + // VerifyBundle needs the local observe handle this seat never created. An + // authenticated member could thus broadcast a bundle for a bogus hash to + // force a peer's fail-closed -- an insider-liveness surface that the blame + // bridge addresses (PR2b-2) and that stays within 1b's accepted + // fail-closed-terminate liveness regression (a single bad actor can already + // kill a round by withholding a bundle). + // + // Under the PERMISSIONED operator set this residual is accepted: the + // triggering seat is operator-authenticated (logged below for attribution), + // the action is liveness-only (fail-closed, never an unsafe/divergent + // signature), and a misbehaving operator is governance-removable. REVISIT + // before any move to a PERMISSIONLESS operator set, where an anonymous, + // costless, non-attributable DoS would warrant the f+1 snapshot-corroboration + // (or resync) fix rather than accepting it. + if e.markLostSync() { + e.logger.Warnf( + "roast transition exchange: seat %d entered lost-sync from an "+ + "unobserved-attempt bundle sent by seat %d (attempt context "+ + "hash %x); failing closed before next selection", + e.member, msg.Sender, hash, + ) + } + return + } + e.verifyAndStore(bundle) +} + +// markLostSync records that this seat received a transition for an attempt it +// never observed -- it fell behind the group's committed ROAST attempt chain. +// It returns true only on the first transition into lost-sync, so the caller can +// attribute the triggering bundle exactly once (the listener may keep receiving +// such bundles -- including a spammer's -- while lost-sync stays latched). +func (e *RoastTransitionExchange) markLostSync() bool { + return e.lostSync.CompareAndSwap(false, true) +} + +// HasLostSync reports whether this seat fell behind the group's committed ROAST +// attempt chain (it received a transition for an attempt it never observed). The +// retry loop checks it before selection and fails closed when true. +func (e *RoastTransitionExchange) HasLostSync() bool { + return e.lostSync.Load() +} + +// BroadcastForcedSnapshot publishes this seat's proof-of-attendance snapshot for +// the attempt, recording it locally BEFORE the broadcast so the censorship check +// on the returned bundle is meaningful. A no-op when the seat has no observe +// binding for the attempt or signing fails. +// +// RFC-21 Phase 7.3 PR2b-2 step 2 (the blame bridge): the snapshot carries the REAL +// evidence the coarse receive loop captured for this attempt, if any. The coarse +// path stashes its recorder snapshot keyed by the same (RoastSessionID, member, +// attemptHash); this consumes it so the seat's broadcast -- and therefore the +// elected coordinator's aggregated bundle -- carries real rejects/overflows/ +// conflicts and NextAttempt's f+1 tally can fire. When the stash is empty (the +// seat observed nothing) the snapshot is the empty proof-of-attendance one, which +// must still be broadcast so the seat is not silence-parked. +func (e *RoastTransitionExchange) BroadcastForcedSnapshot( + attemptHash [attempt.MessageDigestLength]byte, +) { + binding, ok := observedAttempt(e.roastSessionID, e.member, attemptHash) + if !ok { + return + } + // takePendingEvidence returns the zero Evidence + nil proofs on a miss, which + // NewLocalEvidenceSnapshot renders as the empty proof-of-attendance snapshot -- + // still broadcast so the seat is not silence-parked. When present, the snapshot + // carries the coarse path's evidence and/or the interactive path's + // coordinator-equivocation proofs (RFC-21 Phase 7.3 PR2b-2 step 2b); the + // constructor sorts + owns the proofs and the single signing happens below. + evidence, proofs, _ := takePendingEvidence(e.roastSessionID, e.member, attemptHash) + snapshot := roast.NewLocalEvidenceSnapshot(e.member, attemptHash, evidence, proofs...) + payload, err := snapshot.SignableBytes() + if err != nil { + e.logger.Warnf("roast transition: forced snapshot signable bytes: [%v]", err) + return + } + signature, err := e.deps.Signer.Sign(payload) + if err != nil { + e.logger.Warnf("roast transition: sign forced snapshot: [%v]", err) + return + } + snapshot.OperatorSignature = signature + + // Marshal first so the wire bytes are fixed, then record OWN before + // broadcasting: the elected coordinator must have it for aggregation, and + // recording-before-broadcast makes VerifyBundle's self-submission censorship + // check meaningful -- the recorded snapshot is byte-identical to the + // broadcast one and to the copy peers aggregate. + envelope, err := snapshot.Marshal() + if err != nil { + e.logger.Warnf("roast transition: marshal forced snapshot: [%v]", err) + return + } + if err := e.deps.Coordinator.RecordEvidence(binding.handle, snapshot); err != nil { + e.logger.Warnf("roast transition: record own snapshot failed: [%v]", err) + return + } + e.bus.Broadcast(RunnerMessage{ + Type: RunnerMsgEvidenceSnapshot, + Sender: e.member, + Attempt: attemptHash, + Payload: envelope, + }) +} + +// AggregateAndBroadcast aggregates the collected snapshots into a +// coordinator-signed bundle, stores it locally via the same verify+store path +// receivers use, and broadcasts it. A no-op on any seat that is not the attempt's +// elected coordinator (AggregateBundle returns ErrNotAggregator) or that has no +// observe binding. +func (e *RoastTransitionExchange) AggregateAndBroadcast( + attemptHash [attempt.MessageDigestLength]byte, +) { + binding, ok := observedAttempt(e.roastSessionID, e.member, attemptHash) + if !ok { + return + } + bundle, err := e.deps.Coordinator.AggregateBundle(binding.handle) + if err != nil { + // Not the elected coordinator, already transitioned, or an empty bundle: + // nothing to broadcast. Non-elected seats reach here harmlessly. + return + } + + // Store our own record the same way receivers do, then broadcast (the bus + // does not echo our own message back). + e.verifyAndStore(bundle) + + envelope, err := bundle.Marshal() + if err != nil { + e.logger.Warnf("roast transition: marshal bundle: [%v]", err) + return + } + e.bus.Broadcast(RunnerMessage{ + Type: RunnerMsgTransitionBundle, + Sender: e.member, + Attempt: attemptHash, + Payload: envelope, + }) +} + +// verifyAndStore is the single verify+store path both receivers (onBundle) and +// the producing coordinator (AggregateAndBroadcast) use, so a stored record is +// always a verified one. It resolves this seat's observe binding from the +// bundle's signed AttemptContextHash, verifies the bundle against that handle, +// stores the next-attempt record, and clears the consumed observe binding. +func (e *RoastTransitionExchange) verifyAndStore(bundle *roast.TransitionMessage) { + hash := bundle.AttemptContextHashArray() + binding, ok := observedAttempt(e.roastSessionID, e.member, hash) + if !ok { + return + } + if err := e.deps.Coordinator.VerifyBundle(binding.handle, bundle); err != nil { + e.logger.Warnf("roast transition: verify bundle failed: [%v]", err) + return + } + // Defensive: the bundle's hash must match the binding's bound context hash + // (VerifyBundle binds to the handle, but keep the record self-consistent). + boundHash := binding.context.Hash() + if !bytes.Equal(hash[:], boundHash[:]) { + return + } + RecordRoastTransition(e.roastSessionID, e.member, RoastTransitionRecord{ + Bundle: bundle, + PreviousHandle: binding.handle, + PreviousContext: binding.context, + DkgGroupPublicKey: binding.dkgGroupPublicKey, + }) + clearObservedAttempt(e.roastSessionID, e.member, hash) +} diff --git a/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry_test.go b/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry_test.go new file mode 100644 index 0000000000..c6243e31c0 --- /dev/null +++ b/pkg/frost/signing/roast_transition_exchange_frost_native_roast_retry_test.go @@ -0,0 +1,976 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "context" + "crypto/sha256" + "sync" + "testing" + "time" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// captureBus is a RunnerBus that records broadcasts so a test can deliver them +// to peers by hand -- driving the exchange synchronously, with no listener +// goroutine or delivery timing. Subscribe returns an undrained subscriber; the +// tests cancel the exchange ctx so listen() exits immediately and the test calls +// onSnapshot/onBundle directly. +type captureBus struct { + mu sync.Mutex + broadcasts []RunnerMessage +} + +func (b *captureBus) Broadcast(msg RunnerMessage) { + b.mu.Lock() + defer b.mu.Unlock() + // Own the payload so a later mutation cannot change what we captured. + cloned := msg + cloned.Payload = append([]byte(nil), msg.Payload...) + b.broadcasts = append(b.broadcasts, cloned) +} + +func (b *captureBus) Subscribe() *RunnerBusSubscriber { + return &RunnerBusSubscriber{ + commitments: make(chan RunnerMessage, 1), + signingPackages: make(chan RunnerMessage, 1), + shares: make(chan RunnerMessage, 1), + evidenceSnapshots: make(chan RunnerMessage, 1), + transitionBundles: make(chan RunnerMessage, 1), + seen: map[[sha256.Size]byte]struct{}{}, + } +} + +func (b *captureBus) only(t RunnerMessageType) []RunnerMessage { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]RunnerMessage, 0, len(b.broadcasts)) + for _, m := range b.broadcasts { + if m.Type == t { + out = append(out, m) + } + } + return out +} + +// fixedSigner returns a fixed non-empty signature. The wire encoders reject an +// empty signature, so the NoOp signer (which returns nil) cannot be used here; +// the NoOp verifier still accepts this fixed signature, so the test exercises the +// exchange flow without a real signature pipeline. +type fixedSigner struct{} + +func (fixedSigner) Sign(_ []byte) ([]byte, error) { return []byte{0x01}, nil } + +type exchangeNode struct { + member group.MemberIndex + coord roast.Coordinator + bus *captureBus + ex *RoastTransitionExchange +} + +// newExchangeTestNodes builds one node per included member: each gets its own +// in-memory coordinator, begins the SAME attempt context (so all nodes elect the +// same coordinator deterministically), records its observe binding, and wires an +// exchange over its own capture bus (the listener blocks on the unfed bus until +// test end; the tests drive the exchange methods directly). +func newExchangeTestNodes( + t *testing.T, + roastSessionID string, + ctx attempt.AttemptContext, + dkgKey []byte, +) map[group.MemberIndex]*exchangeNode { + t.Helper() + hash := ctx.Hash() + nodes := map[group.MemberIndex]*exchangeNode{} + // A test-lifetime ctx: listen() blocks harmlessly on the capture bus's unfed + // streams (the bus captures broadcasts rather than delivering them), and the + // tests drive onSnapshot/onBundle directly. Cancelling only at test end keeps + // the session-end defer-clear from wiping the bindings before the assertions. + exchangeCtx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + for _, m := range ctx.IncludedSet { + coord := roast.NewInMemoryCoordinatorWithSigning( + m, fixedSigner{}, roast.NoOpSignatureVerifier(), + ) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt for member %d: %v", m, err) + } + recordObservedAttempt(roastSessionID, m, hash, observedAttemptBinding{ + handle: handle, + context: ctx, + dkgGroupPublicKey: dkgKey, + }) + bus := &captureBus{} + ex := NewRoastTransitionExchange( + exchangeCtx, + log.Logger("exchange-test"), + bus, + RoastRetryDeps{ + Coordinator: coord, + Signer: fixedSigner{}, + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(m), + }, + roastSessionID, + m, + ) + nodes[m] = &exchangeNode{member: m, coord: coord, bus: bus, ex: ex} + } + return nodes +} + +func newExchangeTestContext( + t *testing.T, + roastSessionID string, + included []group.MemberIndex, + dkgKey []byte, +) attempt.AttemptContext { + t.Helper() + var digest [attempt.MessageDigestLength]byte + digest[0] = 0xaa + ctx, err := attempt.NewAttemptContextWithParking( + roastSessionID, "exchange-key-group", dkgKey, digest, 0, included, nil, nil, + ) + if err != nil { + t.Fatalf("build attempt context: %v", err) + } + return ctx +} + +// TestRoastTransitionExchange_ProducesRecordsAcrossNodes drives the full +// failed-attempt exchange by hand: every seat broadcasts a forced snapshot, the +// elected coordinator collects them and aggregates+broadcasts the bundle, and +// every seat ends with a verified next-attempt transition record. +func TestRoastTransitionExchange_ProducesRecordsAcrossNodes(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + + roastSessionID := "exchange-records-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x01, 0x02, 0x03} + + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := ctx.Hash() + nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey) + + // Find the deterministically elected coordinator. + var elected group.MemberIndex + for _, n := range nodes { + binding, ok := observedAttempt(roastSessionID, n.member, hash) + if !ok { + t.Fatalf("missing observe binding for member %d", n.member) + } + e, err := n.coord.SelectedCoordinator(binding.handle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + elected = e + break + } + + // 1. Every participating seat broadcasts a forced snapshot. + for _, m := range included { + nodes[m].ex.BroadcastForcedSnapshot(hash) + } + + // 2. Deliver each seat's snapshot to the elected coordinator's onSnapshot. + for _, m := range included { + if m == elected { + continue // elected already recorded its own in BroadcastForcedSnapshot + } + snaps := nodes[m].bus.only(RunnerMsgEvidenceSnapshot) + if len(snaps) != 1 { + t.Fatalf("member %d expected to broadcast 1 snapshot, got %d", m, len(snaps)) + } + nodes[elected].ex.onSnapshot(snaps[0]) + } + + // 3. The elected coordinator aggregates + broadcasts the bundle (a no-op on + // the others). + for _, m := range included { + nodes[m].ex.AggregateAndBroadcast(hash) + } + + bundles := nodes[elected].bus.only(RunnerMsgTransitionBundle) + if len(bundles) != 1 { + t.Fatalf("elected coordinator must broadcast exactly one bundle, got %d", len(bundles)) + } + for _, m := range included { + if m == elected { + continue + } + if got := nodes[m].bus.only(RunnerMsgTransitionBundle); len(got) != 0 { + t.Fatalf("non-elected member %d must not broadcast a bundle, got %d", m, len(got)) + } + } + + // 4. Deliver the bundle to every other seat's onBundle. + for _, m := range included { + if m == elected { + continue + } + nodes[m].ex.onBundle(bundles[0]) + } + + // 5. Every seat must now hold a verified transition record, and its observe + // binding must be cleared (consumed). + for _, m := range included { + if _, ok := RoastTransitionForSession(roastSessionID, m); !ok { + t.Fatalf("member %d must hold a transition record after the exchange", m) + } + if _, ok := observedAttempt(roastSessionID, m, hash); ok { + t.Fatalf("member %d observe binding must be cleared after storing the record", m) + } + } +} + +// produceTransitionBundleForTest runs the forced-snapshot + aggregate flow across +// the included nodes by hand and returns the elected coordinator's broadcast +// transition bundle message plus the elected member. +func produceTransitionBundleForTest( + t *testing.T, + roastSessionID string, + ctx attempt.AttemptContext, + nodes map[group.MemberIndex]*exchangeNode, +) (RunnerMessage, group.MemberIndex) { + t.Helper() + hash := ctx.Hash() + + var elected group.MemberIndex + for _, n := range nodes { + binding, ok := observedAttempt(roastSessionID, n.member, hash) + if !ok { + t.Fatalf("missing observe binding for member %d", n.member) + } + e, err := n.coord.SelectedCoordinator(binding.handle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + elected = e + break + } + + for _, m := range ctx.IncludedSet { + nodes[m].ex.BroadcastForcedSnapshot(hash) + } + for _, m := range ctx.IncludedSet { + if m == elected { + continue + } + snaps := nodes[m].bus.only(RunnerMsgEvidenceSnapshot) + if len(snaps) != 1 { + t.Fatalf("member %d expected to broadcast 1 snapshot, got %d", m, len(snaps)) + } + nodes[elected].ex.onSnapshot(snaps[0]) + } + nodes[elected].ex.AggregateAndBroadcast(hash) + + bundles := nodes[elected].bus.only(RunnerMsgTransitionBundle) + if len(bundles) != 1 { + t.Fatalf("elected coordinator must broadcast exactly one bundle, got %d", len(bundles)) + } + return bundles[0], elected +} + +// TestRoastTransitionExchange_LostSyncOnUnobservedBundle asserts a seat whose +// listener receives a transition bundle for an attempt it NEVER observed (it +// skipped a window the others committed) trips lost sync, so the retry loop can +// fail closed before selecting from a stale position (Codex lost-sync correction). +func TestRoastTransitionExchange_LostSyncOnUnobservedBundle(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + + roastSessionID := "exchange-lost-sync-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x04, 0x05} + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey) + + bundle, _ := produceTransitionBundleForTest(t, roastSessionID, ctx, nodes) + + // A lagging seat (member 4) that never observed this attempt -- its listener + // receives the bundle the committed seats produced. + var lagging group.MemberIndex = 4 + laggingCtx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + laggingEx := NewRoastTransitionExchange( + laggingCtx, + log.Logger("exchange-test-lagging"), + &captureBus{}, + RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinatorWithSigning( + lagging, fixedSigner{}, roast.NoOpSignatureVerifier(), + ), + Signer: fixedSigner{}, + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(lagging), + }, + roastSessionID, + lagging, + ) + + if laggingEx.HasLostSync() { + t.Fatal("a seat must not start in lost sync") + } + laggingEx.onBundle(bundle) + if !laggingEx.HasLostSync() { + t.Fatal("a bundle for an attempt this seat never observed must trip lost sync") + } +} + +// TestRoastTransitionExchange_ConsumedRetransmitDoesNotLoseSync asserts that a +// duplicate bundle for an attempt this seat already observed and consumed is a +// benign no-op -- the observed-history marker (retained past the binding's +// clear) keeps a retransmit from falsely tripping lost sync. +func TestRoastTransitionExchange_ConsumedRetransmitDoesNotLoseSync(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + + roastSessionID := "exchange-retransmit-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x06} + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := ctx.Hash() + nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey) + + bundle, elected := produceTransitionBundleForTest(t, roastSessionID, ctx, nodes) + + // A non-elected receiver consumes the bundle: it stores the record and clears + // its observe binding, leaving only the observed-history marker. + var receiver group.MemberIndex + for _, m := range included { + if m != elected { + receiver = m + break + } + } + nodes[receiver].ex.onBundle(bundle) + if _, ok := RoastTransitionForSession(roastSessionID, receiver); !ok { + t.Fatalf("receiver %d must hold a transition record after consuming", receiver) + } + if _, ok := observedAttempt(roastSessionID, receiver, hash); ok { + t.Fatalf("receiver %d binding must be cleared after consuming", receiver) + } + + // The same bundle re-delivered (a retransmit) must not trip lost sync. + nodes[receiver].ex.onBundle(bundle) + if nodes[receiver].ex.HasLostSync() { + t.Fatal("a retransmit of an already-consumed bundle must not trip lost sync") + } +} + +// TestRoastTransitionExchange_MultiSeatElectedSeatAggregates is the PR2b-1.5 +// acceptance test: when an operator controls multiple local seats and the elected +// ROAST coordinator is one of them, that seat aggregates the transition bundle +// using ITS OWN per-member coordinator (from the per-member registry). Pre-fix a +// single process-wide coordinator bound to ONE SelfMember returned ErrNotAggregator +// whenever the elected seat differed from it -> no bundle -> the next retry +// fail-closed for the whole group. +// +// NOTE on local fanout (Codex's guardrail): this test delivers the sibling seats' +// snapshots to the elected seat's exchange directly. In production the per-seat +// exchanges share ONE wallet BroadcastChannel; a node's own broadcast reaches its +// OTHER local seats' subscribers via the channel's self-delivery (libp2p FloodSub +// delivers a node's publishes to its own subscriptions, and the membership +// validator passes own-author). That self-delivery is a transport-contract +// assumption to confirm at prod-wiring; if a transport does not self-deliver, +// explicit local fanout is the remedy. The aggregation fix proven here is +// independent of how the sibling snapshot arrives. +func TestRoastTransitionExchange_MultiSeatElectedSeatAggregates(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + + roastSessionID := "multiseat-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x0d} + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := ctx.Hash() + + // Deterministic elected coordinator for this context. + probe := roast.NewInMemoryCoordinatorWithSigning(0, fixedSigner{}, roast.NoOpSignatureVerifier()) + probeHandle, err := probe.BeginAttempt(ctx) + if err != nil { + t.Fatalf("probe begin: %v", err) + } + elected, err := probe.SelectedCoordinator(probeHandle) + if err != nil { + t.Fatalf("probe elected: %v", err) + } + + // One operator controls ALL included seats (the extreme multi-seat case): each + // seat gets its OWN coordinator bound to its member, registered per-member, + // sharing the operator key (fixedSigner). + for _, m := range included { + RegisterRoastRetryCoordinatorForMember(m, RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinatorWithSigning(m, fixedSigner{}, roast.NoOpSignatureVerifier()), + Signer: fixedSigner{}, + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(m), + }) + } + + // Build an exchange + observe binding for EVERY local seat from ITS OWN + // registered per-member deps (the path the controller takes per signer); keep + // each seat's capture bus to inspect what it broadcasts. + exCtx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + buses := map[group.MemberIndex]*captureBus{} + exchanges := map[group.MemberIndex]*RoastTransitionExchange{} + for _, m := range included { + deps, ok := RegisteredRoastRetryCoordinatorForMember(m) + if !ok { + t.Fatalf("deps for member %d missing", m) + } + handle, err := deps.Coordinator.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin attempt for %d: %v", m, err) + } + recordObservedAttempt(roastSessionID, m, hash, observedAttemptBinding{ + handle: handle, + context: ctx, + dkgGroupPublicKey: dkgKey, + }) + bus := &captureBus{} + buses[m] = bus + exchanges[m] = NewRoastTransitionExchange(exCtx, log.Logger("multiseat"), bus, deps, roastSessionID, m) + } + + // Every seat broadcasts its forced snapshot; the elected seat collects all (its + // own recorded in BroadcastForcedSnapshot, the siblings' delivered to it). + for _, m := range included { + exchanges[m].BroadcastForcedSnapshot(hash) + } + for _, m := range included { + if m == elected { + continue + } + snaps := buses[m].only(RunnerMsgEvidenceSnapshot) + if len(snaps) != 1 { + t.Fatalf("member %d expected to broadcast 1 forced snapshot, got %d", m, len(snaps)) + } + exchanges[elected].onSnapshot(snaps[0]) + } + + // Each seat runs aggregation. + for _, m := range included { + exchanges[m].AggregateAndBroadcast(hash) + } + + // The elected local seat aggregated with ITS OWN per-member coordinator: a + // transition record + exactly one broadcast bundle. + if _, ok := RoastTransitionForSession(roastSessionID, elected); !ok { + t.Fatal("the elected local seat must aggregate + store a record with its own per-member coordinator") + } + if got := buses[elected].only(RunnerMsgTransitionBundle); len(got) != 1 { + t.Fatalf("the elected seat must broadcast exactly one bundle, got %d", len(got)) + } + + // The NON-elected sibling seats must NOT aggregate. This is the non-vacuous half: + // pre-fix all seats shared ONE coordinator bound to the elected member, so a + // sibling's AggregateBundle would have run as the elected member and broadcast a + // bundle; per-member coordinators (bound to members != elected) make it + // ErrNotAggregator -> no bundle. + var sibling group.MemberIndex + for _, m := range included { + if m == elected { + continue + } + if got := buses[m].only(RunnerMsgTransitionBundle); len(got) != 0 { + t.Fatalf("non-elected local seat %d must not aggregate a bundle, got %d", m, len(got)) + } + sibling = m + } + + // A sibling seat receives the elected seat's bundle and stores ITS OWN + // next-attempt record with its own coordinator (the multi-seat sibling-unparking + // path that lets a non-elected local seat advance). + exchanges[sibling].onBundle(buses[elected].only(RunnerMsgTransitionBundle)[0]) + if _, ok := RoastTransitionForSession(roastSessionID, sibling); !ok { + t.Fatal("a sibling local seat must store its own transition record from the elected seat's bundle") + } +} + +// TestRoastTransitionExchange_SucceededSeatDoesNotStorePeerFailureBundle asserts +// the core B3 outcome: after a seat clears its observe binding on local success, a +// peer's failure bundle for that attempt is neither stored as a transition record +// (the attempt was won, not failed) nor mistaken for lost sync (the observed +// marker keeps it a benign no-op). With no fresh record the seat's next selection +// then fails closed -- the honest fail-closed-after-success path. +func TestRoastTransitionExchange_SucceededSeatDoesNotStorePeerFailureBundle(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + + roastSessionID := "exchange-success-nostore-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x07} + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := ctx.Hash() + nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey) + + bundle, elected := produceTransitionBundleForTest(t, roastSessionID, ctx, nodes) + + // A non-elected seat completed the attempt locally: it clears its observe + // binding (B3), keeping only the observed-history marker. It has not consumed a + // record (the helper delivered the bundle only to the elected coordinator). + var succeeded group.MemberIndex + for _, m := range included { + if m != elected { + succeeded = m + break + } + } + ClearObservedAttemptOnLocalSuccess(roastSessionID, succeeded, hash) + + // The peer's failure bundle now arrives at the succeeded seat's listener. + nodes[succeeded].ex.onBundle(bundle) + + if _, ok := RoastTransitionForSession(roastSessionID, succeeded); ok { + t.Fatal("a succeeded seat must not store a peer's failure transition for its won attempt") + } + if nodes[succeeded].ex.HasLostSync() { + t.Fatal("a bundle for a succeeded (observed) attempt must not trip lost sync") + } +} + +// TestRoastTransitionExchange_SessionEndClearsObserveBindings asserts that when +// the exchange's session context ends, its listener clears the observe bindings +// this seat did not consume per-attempt (e.g. a signing whose attempts all +// succeeded), rather than leaking them until the TTL sweep. +func TestRoastTransitionExchange_SessionEndClearsObserveBindings(t *testing.T) { + ResetObservedAttemptRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + + roastSessionID := "exchange-session-end" + var hash [attempt.MessageDigestLength]byte + hash[0] = 0x7e + recordObservedAttempt(roastSessionID, 1, hash, observedAttemptBinding{}) + + ctx, cancel := context.WithCancel(context.Background()) + _ = NewRoastTransitionExchange( + ctx, + log.Logger("exchange-test"), + &captureBus{}, + RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinatorWithSigning( + 1, fixedSigner{}, roast.NoOpSignatureVerifier(), + ), + Signer: fixedSigner{}, + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }, + roastSessionID, + 1, + ) + if !ObservedAttemptStoredForTest(roastSessionID, 1) { + t.Fatal("binding should exist before session end") + } + + cancel() // session end -> listener exits -> defer clears bindings. + + deadline := time.Now().Add(2 * time.Second) + for ObservedAttemptStoredForTest(roastSessionID, 1) { + if time.Now().After(deadline) { + t.Fatal("session end must clear the observe binding") + } + time.Sleep(5 * time.Millisecond) + } +} + +// TestRoastTransitionExchange_NonElectedDoesNotAggregate asserts a seat that is +// not the elected coordinator produces no bundle and stores no record from +// AggregateAndBroadcast (only the elected seat aggregates). +func TestRoastTransitionExchange_NonElectedDoesNotAggregate(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + + roastSessionID := "exchange-nonelected-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x04, 0x05, 0x06} + + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := ctx.Hash() + nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey) + + var elected group.MemberIndex + binding, _ := observedAttempt(roastSessionID, 1, hash) + elected, _ = nodes[1].coord.SelectedCoordinator(binding.handle) + + var nonElected group.MemberIndex + for _, m := range included { + if m != elected { + nonElected = m + break + } + } + + nodes[nonElected].ex.BroadcastForcedSnapshot(hash) + nodes[nonElected].ex.AggregateAndBroadcast(hash) + + if got := nodes[nonElected].bus.only(RunnerMsgTransitionBundle); len(got) != 0 { + t.Fatalf("non-elected seat must not broadcast a bundle, got %d", len(got)) + } + if _, ok := RoastTransitionForSession(roastSessionID, nonElected); ok { + t.Fatal("non-elected seat must not store a transition record from AggregateAndBroadcast") + } +} + +// TestRoastTransitionExchange_StashedEvidenceDrivesExclusion is the end-to-end +// blame-bridge proof (RFC-21 Phase 7.3 PR2b-2 step 2): coarse-path evidence stashed +// by the receive loop is carried by BroadcastForcedSnapshot into the aggregated +// bundle, so NextAttempt's f+1 accuser tally excludes the blamed member -- the +// exclusion the pre-bridge forced-EMPTY snapshots could never trigger. +func TestRoastTransitionExchange_StashedEvidenceDrivesExclusion(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + roastSessionID := "exchange-blame-bridge-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x09, 0x0a} + // original group size 3; quorum = ExclusionAccuserQuorum(3, threshold) = + // 3 - 2 + 1 = 2, so two accusers establish a permanent reject exclusion. + const threshold uint = 2 + + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := ctx.Hash() + nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey) + + // Members 1 and 2 each observed a validation reject against member 3 during the + // coarse receive loop; their submit stashed it. Member 3 observed nothing and + // broadcasts only an empty proof-of-attendance snapshot. + rejectAgainst3 := attempt.Evidence{ + Rejects: map[group.MemberIndex][]attempt.RejectEntry{ + 3: {{Reason: "attempt_context_hash_mismatch", Count: 1}}, + }, + } + stashPendingEvidence(roastSessionID, 1, hash, rejectAgainst3) + stashPendingEvidence(roastSessionID, 2, hash, rejectAgainst3) + + bundleMsg, elected := produceTransitionBundleForTest(t, roastSessionID, ctx, nodes) + + // The aggregated bundle must carry real reject evidence from >=2 accusers, not + // the forced-empty snapshots of the pre-bridge design. + bundle := &roast.TransitionMessage{} + if err := bundle.Unmarshal(bundleMsg.Payload); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + accusers := 0 + for i := range bundle.Bundle { + if len(bundle.Bundle[i].Rejects) > 0 { + accusers++ + } + } + if accusers < 2 { + t.Fatalf("bundle must carry reject evidence from >=2 accusers; got %d", accusers) + } + + // BroadcastForcedSnapshot consumed the stash. + if PendingEvidenceStashedForTest(roastSessionID, 1) || + PendingEvidenceStashedForTest(roastSessionID, 2) { + t.Fatal("BroadcastForcedSnapshot must consume the stash") + } + + // A non-elected accuser verifies the bundle against its own observe handle and + // computes the next attempt: member 3 meets the f+1 reject quorum -> excluded. + var verifier group.MemberIndex + for _, m := range included { + if m != elected { + verifier = m + break + } + } + binding, ok := observedAttempt(roastSessionID, verifier, hash) + if !ok { + t.Fatalf("non-elected seat %d must still hold its observe binding", verifier) + } + if err := nodes[verifier].coord.VerifyBundle(binding.handle, bundle); err != nil { + t.Fatalf("verify bundle: %v", err) + } + next, err := nodes[verifier].coord.NextAttempt(binding.handle, bundle, threshold, dkgKey) + if err != nil { + t.Fatalf("next attempt: %v", err) + } + + excluded3 := false + for _, m := range next.ExcludedSet { + if m == 3 { + excluded3 = true + } + } + if !excluded3 { + t.Fatalf("member 3 must be excluded by the f+1 reject quorum; excluded=%v", next.ExcludedSet) + } + for _, m := range next.IncludedSet { + if m == 3 { + t.Fatalf("excluded member 3 must not be in the next included set; included=%v", next.IncludedSet) + } + } +} + +// TestRoastTransitionExchange_StashedProofsDriveCoordinatorEquivocationExclusion is +// the end-to-end blame bridge for coordinator equivocation (RFC-21 Phase 7.3 PR2b-2 +// step 2b): coordinator-signed package proofs the interactive path stashes are +// carried by BroadcastForcedSnapshot into the aggregated bundle, where NextAttempt's +// cross-observer proof tally instantly excludes a coordinator that signed two +// body-different packages -- the TARGETED case where no single observer saw both. +func TestRoastTransitionExchange_StashedProofsDriveCoordinatorEquivocationExclusion(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + roastSessionID := "exchange-equivocation-session" + included := []group.MemberIndex{1, 2, 3} + dkgKey := []byte{0x0b, 0x0c} + const threshold uint = 2 // excluding the single coordinator leaves 2 >= threshold + + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := ctx.Hash() + nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey) + + // Determine the deterministically elected coordinator. + binding, ok := observedAttempt(roastSessionID, 1, hash) + if !ok { + t.Fatal("missing observe binding for member 1") + } + elected, err := nodes[1].coord.SelectedCoordinator(binding.handle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + + // The coordinator distributed TWO body-different signed packages for this + // attempt; members 1 and 2 each retained a DIFFERENT one (the targeted split: + // no single observer saw both). Build them as authentic coordinator-signed + // proofs -- the harness's NoOpSignatureVerifier accepts the signature, and + // AuthenticateSigningPackage still checks coordinator id + attempt hash. + makeProof := func(body string) []byte { + pkg := &roast.SigningPackage{ + AttemptContextHash: append([]byte(nil), hash[:]...), + CoordinatorIDValue: uint32(elected), + SigningPackageBytes: []byte(body), + CoordinatorSignature: []byte{0x01}, + } + env, perr := pkg.Marshal() + if perr != nil { + t.Fatalf("marshal proof: %v", perr) + } + return env + } + stashPendingCoordinatorProofs(roastSessionID, 1, hash, [][]byte{makeProof("frost-package-A")}) + stashPendingCoordinatorProofs(roastSessionID, 2, hash, [][]byte{makeProof("frost-package-B")}) + + bundleMsg, electedFromBundle := produceTransitionBundleForTest(t, roastSessionID, ctx, nodes) + if electedFromBundle != elected { + t.Fatalf("elected mismatch: %d vs %d", electedFromBundle, elected) + } + + bundle := &roast.TransitionMessage{} + if err := bundle.Unmarshal(bundleMsg.Payload); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + // The bundle must carry >=2 proof envelopes across its snapshots (the pre-2b + // design carried none), and the stash must be consumed. + totalProofs := 0 + for i := range bundle.Bundle { + totalProofs += len(bundle.Bundle[i].CoordinatorPackageProofs) + } + if totalProofs < 2 { + t.Fatalf("bundle must carry >=2 coordinator package proofs; got %d", totalProofs) + } + if PendingEvidenceStashedForTest(roastSessionID, 1) || + PendingEvidenceStashedForTest(roastSessionID, 2) { + t.Fatal("BroadcastForcedSnapshot must consume the proof stash") + } + + // A non-elected seat verifies the bundle and computes the next attempt: two + // distinct authentic coordinator-signed bodies -> instant coordinator exclusion. + var verifier group.MemberIndex + for _, m := range included { + if m != elected { + verifier = m + break + } + } + vbinding, ok := observedAttempt(roastSessionID, verifier, hash) + if !ok { + t.Fatalf("non-elected seat %d must still hold its observe binding", verifier) + } + if err := nodes[verifier].coord.VerifyBundle(vbinding.handle, bundle); err != nil { + t.Fatalf("verify bundle: %v", err) + } + next, err := nodes[verifier].coord.NextAttempt(vbinding.handle, bundle, threshold, dkgKey) + if err != nil { + t.Fatalf("next attempt: %v", err) + } + + excludedCoord := false + for _, m := range next.ExcludedSet { + if m == elected { + excludedCoord = true + } + } + if !excludedCoord { + t.Fatalf("equivocating coordinator %d must be excluded; excluded=%v", elected, next.ExcludedSet) + } + for _, m := range next.IncludedSet { + if m == elected { + t.Fatalf("excluded coordinator %d must not remain in the included set; included=%v", elected, next.IncludedSet) + } + } +} + +// TestRoastTransitionExchange_CombinedFaultsExcludeBoth is the 2a+2b coexistence +// capstone (RFC-21 Phase 7.3 PR2b-2 step 2c): ONE transition bundle carrying BOTH +// f+1 reject evidence (against a bad signer) AND coordinator-equivocation proofs +// (two distinct authentic bodies) excludes BOTH the rejected member and the +// equivocating coordinator in a single NextAttempt -- the union stash and the two +// independent NextAttempt paths working together, including snapshots that carry +// evidence AND a proof at once. +func TestRoastTransitionExchange_CombinedFaultsExcludeBoth(t *testing.T) { + ResetObservedAttemptRegistryForTest() + ResetRoastTransitionRegistryForTest() + ResetPendingEvidenceRegistryForTest() + t.Cleanup(ResetObservedAttemptRegistryForTest) + t.Cleanup(ResetRoastTransitionRegistryForTest) + t.Cleanup(ResetPendingEvidenceRegistryForTest) + + roastSessionID := "exchange-combined-faults-session" + included := []group.MemberIndex{1, 2, 3, 4, 5} + dkgKey := []byte{0x0d, 0x0e} + // original size 5; quorum = 5 - threshold + 1 = 3 at threshold 3. Excluding the + // rejected member + the coordinator leaves 3 == threshold -> feasible. + const threshold uint = 3 + + ctx := newExchangeTestContext(t, roastSessionID, included, dkgKey) + hash := ctx.Hash() + nodes := newExchangeTestNodes(t, roastSessionID, ctx, dkgKey) + + binding, ok := observedAttempt(roastSessionID, 1, hash) + if !ok { + t.Fatal("missing observe binding for member 1") + } + elected, err := nodes[1].coord.SelectedCoordinator(binding.handle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + + // A bad signer to reject, distinct from the coordinator. + var rejected group.MemberIndex + for _, m := range included { + if m != elected { + rejected = m + break + } + } + // Three credible accusers (members other than the rejected one) reach the f+1 + // reject quorum against it. + accusers := make([]group.MemberIndex, 0, 3) + for _, m := range included { + if m != rejected { + accusers = append(accusers, m) + if len(accusers) == 3 { + break + } + } + } + rejectEvidence := attempt.Evidence{ + Rejects: map[group.MemberIndex][]attempt.RejectEntry{ + rejected: {{Reason: "validation_gate_rejected", Count: 1}}, + }, + } + for _, a := range accusers { + stashPendingEvidence(roastSessionID, a, hash, rejectEvidence) + } + + // Two of those accusers ALSO each retained a DIFFERENT coordinator package + // (targeted equivocation); their snapshots therefore carry evidence AND a proof. + makeProof := func(body string) []byte { + pkg := &roast.SigningPackage{ + AttemptContextHash: append([]byte(nil), hash[:]...), + CoordinatorIDValue: uint32(elected), + SigningPackageBytes: []byte(body), + CoordinatorSignature: []byte{0x01}, + } + env, perr := pkg.Marshal() + if perr != nil { + t.Fatalf("marshal proof: %v", perr) + } + return env + } + stashPendingCoordinatorProofs(roastSessionID, accusers[0], hash, [][]byte{makeProof("frost-package-A")}) + stashPendingCoordinatorProofs(roastSessionID, accusers[1], hash, [][]byte{makeProof("frost-package-B")}) + + bundleMsg, electedFromBundle := produceTransitionBundleForTest(t, roastSessionID, ctx, nodes) + if electedFromBundle != elected { + t.Fatalf("elected mismatch: %d vs %d", electedFromBundle, elected) + } + + bundle := &roast.TransitionMessage{} + if err := bundle.Unmarshal(bundleMsg.Payload); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + + // Compute the next attempt from a node that is neither the rejected member nor + // the coordinator -- an honest verifier whose observe binding is intact. + var verifier group.MemberIndex + for _, m := range included { + if m != elected && m != rejected { + verifier = m + break + } + } + vbinding, ok := observedAttempt(roastSessionID, verifier, hash) + if !ok { + t.Fatalf("verifier seat %d must hold its observe binding", verifier) + } + if err := nodes[verifier].coord.VerifyBundle(vbinding.handle, bundle); err != nil { + t.Fatalf("verify bundle: %v", err) + } + next, err := nodes[verifier].coord.NextAttempt(vbinding.handle, bundle, threshold, dkgKey) + if err != nil { + t.Fatalf("next attempt: %v", err) + } + + contains := func(s []group.MemberIndex, m group.MemberIndex) bool { + for _, x := range s { + if x == m { + return true + } + } + return false + } + if !contains(next.ExcludedSet, rejected) { + t.Fatalf("f+1-rejected member %d must be excluded; excluded=%v", rejected, next.ExcludedSet) + } + if !contains(next.ExcludedSet, elected) { + t.Fatalf("equivocating coordinator %d must be excluded; excluded=%v", elected, next.ExcludedSet) + } + if contains(next.IncludedSet, rejected) || contains(next.IncludedSet, elected) { + t.Fatalf("excluded members must not remain in included=%v", next.IncludedSet) + } +} diff --git a/pkg/frost/signing/roast_transition_producer_default_build.go b/pkg/frost/signing/roast_transition_producer_default_build.go new file mode 100644 index 0000000000..30b7bffd10 --- /dev/null +++ b/pkg/frost/signing/roast_transition_producer_default_build.go @@ -0,0 +1,17 @@ +//go:build !(frost_native && frost_roast_retry) + +package signing + +// roastTransitionProducerAvailable reports whether this build contains the ROAST +// transition producer (observe + exchange + elected-coordinator aggregation). That +// producer requires BOTH frost_native and frost_roast_retry; this build lacks at +// least one, so no producer exists and the function reports false. +// +// In particular a frost_roast_retry && !frost_native build has the participant +// selector and the coordinator registry but NO producer, so RoastRetryActive +// reports inactive and the selector falls back to the uniform legacy shuffle rather +// than fail-closing every retry against records that can never be created. RFC-21 +// Phase 7.3 PR2b-1b (Codex P2-1). +func roastTransitionProducerAvailable() bool { + return false +} diff --git a/pkg/frost/signing/roast_transition_producer_frost_native_roast_retry.go b/pkg/frost/signing/roast_transition_producer_frost_native_roast_retry.go new file mode 100644 index 0000000000..5f3a22b450 --- /dev/null +++ b/pkg/frost/signing/roast_transition_producer_frost_native_roast_retry.go @@ -0,0 +1,16 @@ +//go:build frost_native && frost_roast_retry + +package signing + +// roastTransitionProducerAvailable reports whether this build contains the ROAST +// transition PRODUCER -- the observe step, the transition exchange, and the +// elected-coordinator aggregation that create transition records. The producer +// lives behind frost_native && frost_roast_retry, so it is present here. +// +// RoastRetryActive and the participant selector gate on it: without a producer no +// transition record can ever exist, so treating ROAST retry as active would +// fail-close every retry (roastAttemptNumber > 0 finds no record) instead of using +// the uniform legacy shuffle. RFC-21 Phase 7.3 PR2b-1b (Codex P2-1). +func roastTransitionProducerAvailable() bool { + return true +} diff --git a/pkg/frost/signing/roast_transition_record.go b/pkg/frost/signing/roast_transition_record.go new file mode 100644 index 0000000000..18c4dcd1bb --- /dev/null +++ b/pkg/frost/signing/roast_transition_record.go @@ -0,0 +1,33 @@ +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// RoastTransitionRecord is the cross-attempt ROAST state the next attempt's +// participant selector consumes to compute its IncludedSet. It is produced by +// the elected-coordinator seat's orchestration cleanup when an attempt fails to +// aggregate, and carries everything NextAttempt needs WITHOUT depending on the +// live attempt-handle registry (which cleanup clears) or recomputing the DKG +// key (which would be nil at the selector): +// +// - Bundle: the coordinator-signed TransitionMessage for the failed attempt. +// - PreviousHandle: the failed attempt's handle (the selector calls +// NextAttempt against it; it survives cleanup here so the selector is not +// racing ClearCurrentAttemptHandleForSession). +// - PreviousContext: the failed attempt's context (the handle's bound context). +// - DkgGroupPublicKey: the group key NextAttempt needs to derive the next +// attempt's seed; passing nil makes NextAttempt produce a context that +// NewActiveRoastAttempt later rejects (seed mismatch). +// +// The type is defined in an untagged file because both the frost_roast_retry +// transition registry and the default-build no-op stub reference it (the +// orchestration cleanup, which calls RecordRoastTransition, compiles in every +// build). +type RoastTransitionRecord struct { + Bundle *roast.TransitionMessage + PreviousHandle roast.AttemptHandle + PreviousContext attempt.AttemptContext + DkgGroupPublicKey []byte +} diff --git a/pkg/frost/signing/round2_share_verifier_frost_native.go b/pkg/frost/signing/round2_share_verifier_frost_native.go new file mode 100644 index 0000000000..50619e7cfe --- /dev/null +++ b/pkg/frost/signing/round2_share_verifier_frost_native.go @@ -0,0 +1,246 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "fmt" + + "github.com/ipfs/go-log/v2" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +var round2ShareVerifierLogger = log.Logger("keep-frost-signing-blame") + +// Round2ShareVerifyingEngine is the slice of the native tbtc-signer engine the +// EngineRound2ShareVerifier needs: a single round-2 FROST share re-verification. +// +// It is defined at the consumer (interface segregation) so the verifier can be +// unit-tested with a one-method fake - no cgo and no full NativeTBTCSignerEngine. +// The registered NativeTBTCSignerEngine is a superset and satisfies it. +type Round2ShareVerifyingEngine interface { + VerifySignatureShare( + sessionID string, + signingPackage []byte, + signatureShare []byte, + memberIdentifier uint16, + taprootMerkleRoot *[32]byte, + ) (NativeShareVerificationVerdict, error) +} + +// Round2ShareVerificationBinding pins an EngineRound2ShareVerifier to ONE +// attempt. +// +// The engine resolves the group key from SessionID and tweaks the verification +// by TaprootMerkleRoot; it cannot tell a valid-but-WRONG session/root from the +// right one, so a mis-bound verifier would make an HONEST share verify invalid +// and produce FALSE blame. The verifier therefore checks the retained package's +// own AttemptContextHash and TaprootMerkleRoot against this binding before it +// calls the engine, refusing (Indeterminate) on any mismatch. +// +// SessionID itself cannot be self-checked by the verifier (it cannot re-derive +// the attempt-context hash here), so the construction site MUST supply a +// SessionID consistent with AttemptContextHash - the RFC-21 attempt-context +// derivation includes the session, so an orchestrator that holds both can assert +// it. That is a hard construction-time contract. +type Round2ShareVerificationBinding struct { + // SessionID names the engine DKG session whose group key the share is + // verified against. Must be non-empty and consistent with AttemptContextHash. + SessionID string + // AttemptContextHash is the attempt this verifier adjudicates (32 bytes). + AttemptContextHash [32]byte + // TaprootMerkleRoot is the taproot script-tree root the signature is tweaked + // by, or nil for a key-path spend. Copied at construction. + TaprootMerkleRoot *[32]byte +} + +// EngineRound2ShareVerifier is the engine-backed roast.Round2ShareVerifier: it +// re-verifies a retained round-2 signature share against an attempt's signing +// package using the Rust engine's pure FROST share verification (the frozen Q1 +// crypto-only boundary - it never inspects operator-signed envelopes for blame; +// that is Round2Collector's job). Immutable after construction and safe for +// concurrent use. +type EngineRound2ShareVerifier struct { + engine Round2ShareVerifyingEngine + sessionID string + attemptContextHash [32]byte + taprootMerkleRoot *[32]byte +} + +var _ roast.Round2ShareVerifier = (*EngineRound2ShareVerifier)(nil) + +// NewEngineRound2ShareVerifier binds an engine-backed verifier to one attempt. +// It errors on a nil engine or an empty SessionID, and copies the binding's +// taproot root so the verifier is immutable and cannot be mutated through the +// caller's pointer. +func NewEngineRound2ShareVerifier( + engine Round2ShareVerifyingEngine, + binding Round2ShareVerificationBinding, +) (*EngineRound2ShareVerifier, error) { + if engine == nil { + return nil, fmt.Errorf( + "roast: EngineRound2ShareVerifier requires a non-nil engine", + ) + } + if binding.SessionID == "" { + return nil, fmt.Errorf( + "roast: EngineRound2ShareVerifier requires a non-empty session id", + ) + } + + var taprootMerkleRoot *[32]byte + if binding.TaprootMerkleRoot != nil { + root := *binding.TaprootMerkleRoot + taprootMerkleRoot = &root + } + + return &EngineRound2ShareVerifier{ + engine: engine, + sessionID: binding.SessionID, + attemptContextHash: binding.AttemptContextHash, + taprootMerkleRoot: taprootMerkleRoot, + }, nil +} + +// VerifyRetainedShare implements roast.Round2ShareVerifier. It unwraps the +// retained, collector-authenticated envelopes, confirms the package belongs to +// THIS verifier's bound attempt and root, then asks the engine to re-verify the +// member's inner FROST share. It fails closed against blame +// (ShareIndeterminate) on every not-the-member's-fault condition; only the +// engine's `invalid` verdict - the member's own share is mathematically invalid +// or undecodable, judged after the engine establishes session/DKG/group/package +// context - yields ShareInvalid. +func (v *EngineRound2ShareVerifier) VerifyRetainedShare( + signingPackageEnvelope []byte, + shareEnvelope []byte, + submitter group.MemberIndex, +) roast.ShareVerificationResult { + // A zero member index is never a valid FROST participant; do not blame. + if submitter == 0 { + v.logIndeterminate("submitter member index is zero", submitter, nil) + return roast.ShareIndeterminate + } + + // The retained package/share envelopes are collector-produced (authenticated, + // then re-marshaled): the member controls only the inner FROST bytes, not the + // envelope framing, so an unmarshal failure here is an internal inconsistency, + // not member fault -> Indeterminate, and the engine is NOT called. + var signingPackage roast.SigningPackage + if err := signingPackage.Unmarshal(signingPackageEnvelope); err != nil { + v.logIndeterminate("retained signing-package envelope did not unmarshal", submitter, err) + return roast.ShareIndeterminate + } + + // Refuse to adjudicate a package that is not THIS attempt's, or whose taproot + // root differs from the bound root: a mis-bound or cross-attempt verifier must + // never turn an honest share into false blame. Fail closed (Indeterminate). + if !bytes.Equal(signingPackage.AttemptContextHash, v.attemptContextHash[:]) { + v.logIndeterminate("retained package attempt-context hash does not match the bound attempt", submitter, nil) + return roast.ShareIndeterminate + } + if !v.taprootMerkleRootMatches(signingPackage.TaprootMerkleRoot) { + v.logIndeterminate("retained package taproot root does not match the bound root", submitter, nil) + return roast.ShareIndeterminate + } + + var shareSubmission roast.ShareSubmission + if err := shareSubmission.Unmarshal(shareEnvelope); err != nil { + v.logIndeterminate("retained share-submission envelope did not unmarshal", submitter, err) + return roast.ShareIndeterminate + } + + // The retained share envelope must actually belong to the member being + // adjudicated: if its own SubmitterID disagrees with submitter, that is a + // caller inconsistency, not member fault. Never verify one member's identity + // against another member's share bytes (that would manufacture false blame). + if shareSubmission.SubmitterID() != submitter { + v.logIndeterminate("retained share submitter id does not match the adjudicated member", submitter, nil) + return roast.ShareIndeterminate + } + + // The retained share must answer THIS package: SigningPackageHash is the + // member's commitment to the package body its share signs over. If it does + // not equal the bound package's body hash, the share is not evidence against + // this package, and verifying it against a package the member never committed + // to would manufacture false blame -> Indeterminate. The collector only + // accepts shares matching the authoritative package, so this is a defensive + // cross-check that keeps the verifier sound for any caller. + packageBodyHash, err := signingPackage.BodyHash() + if err != nil { + v.logIndeterminate("could not hash the retained signing-package body", submitter, err) + return roast.ShareIndeterminate + } + if !bytes.Equal(shareSubmission.SigningPackageHash, packageBodyHash[:]) { + v.logIndeterminate("retained share does not commit to the bound signing package", submitter, nil) + return roast.ShareIndeterminate + } + + // submitter (group.MemberIndex == uint8) widens to the engine's uint16 + // losslessly. The engine classifies the inner FROST bytes: undecodable or + // mathematically invalid member bytes become `invalid` only AFTER it + // establishes session/DKG/group/package context, so passing the raw inner + // bytes through is what makes a garbage-share submitter blamable. + verdict, err := v.engine.VerifySignatureShare( + v.sessionID, + signingPackage.SigningPackageBytes, + shareSubmission.SignatureShare, + uint16(submitter), + v.taprootMerkleRoot, + ) + if err != nil { + // FFI transport / engine-unavailable / decode failure: not member fault. + v.logIndeterminate("engine verify_signature_share failed", submitter, err) + return roast.ShareIndeterminate + } + + switch verdict { + case NativeShareVerdictValid: + return roast.ShareValid + case NativeShareVerdictInvalid: + return roast.ShareInvalid + case NativeShareVerdictIndeterminate: + // Not the member's fault (per the engine's own tri-state); stay silent - + // this is an expected, benign outcome, not a system failure. + return roast.ShareIndeterminate + default: + // An unrecognized verdict must never read as blame. + v.logIndeterminate("engine returned an unrecognized verdict", submitter, nil) + return roast.ShareIndeterminate + } +} + +// taprootMerkleRootMatches reports whether the retained package's taproot root +// equals the bound root, honoring key-path (nil bound root <-> empty package +// root) semantics. +func (v *EngineRound2ShareVerifier) taprootMerkleRootMatches(packageRoot []byte) bool { + if v.taprootMerkleRoot == nil { + // Bound to a key-path spend: the package must also carry no root. + return len(packageRoot) == 0 + } + return bytes.Equal(packageRoot, v.taprootMerkleRoot[:]) +} + +// logIndeterminate records a fail-closed Indeterminate for diagnostics so a +// system failure (FFI/transport, mis-binding, malformed retained bytes) is +// distinguishable from a benign engine `indeterminate`. It logs the member, +// session, and reason (and the error, when any), but NEVER the raw package or +// share bytes. +func (v *EngineRound2ShareVerifier) logIndeterminate( + reason string, + submitter group.MemberIndex, + err error, +) { + if err != nil { + round2ShareVerifierLogger.Warnf( + "round-2 share verification indeterminate for member [%d] on session [%s]: %s: [%v]", + submitter, v.sessionID, reason, err, + ) + return + } + round2ShareVerifierLogger.Warnf( + "round-2 share verification indeterminate for member [%d] on session [%s]: %s", + submitter, v.sessionID, reason, + ) +} diff --git a/pkg/frost/signing/round2_share_verifier_frost_native_test.go b/pkg/frost/signing/round2_share_verifier_frost_native_test.go new file mode 100644 index 0000000000..685d8f331b --- /dev/null +++ b/pkg/frost/signing/round2_share_verifier_frost_native_test.go @@ -0,0 +1,356 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +var errTestEngine = errors.New("engine boom") + +// fakeShareVerifyingEngine is a one-method Round2ShareVerifyingEngine: it +// records the call (to assert the engine is/ isn't invoked and with what +// arguments) and returns a programmed verdict/error. +type fakeShareVerifyingEngine struct { + verdict NativeShareVerificationVerdict + err error + + called bool + gotSessionID string + gotSigningPackage []byte + gotSignatureShare []byte + gotMemberIdentifier uint16 + gotTaprootRoot *[32]byte +} + +func (f *fakeShareVerifyingEngine) VerifySignatureShare( + sessionID string, + signingPackage []byte, + signatureShare []byte, + memberIdentifier uint16, + taprootMerkleRoot *[32]byte, +) (NativeShareVerificationVerdict, error) { + f.called = true + f.gotSessionID = sessionID + f.gotSigningPackage = signingPackage + f.gotSignatureShare = signatureShare + f.gotMemberIdentifier = memberIdentifier + f.gotTaprootRoot = taprootMerkleRoot + return f.verdict, f.err +} + +// testSigningPackage builds a signing package and returns both its on-wire +// envelope and its body hash, so a share can commit to the SAME package the +// verifier is bound to (SigningPackageHash == BodyHash). +func testSigningPackage( + t *testing.T, + attemptContextHash [32]byte, + taprootMerkleRoot []byte, + frostSigningPackage []byte, +) (envelope []byte, bodyHash []byte) { + t.Helper() + pkg := &roast.SigningPackage{ + AttemptContextHash: append([]byte(nil), attemptContextHash[:]...), + CoordinatorIDValue: 1, + SigningPackageBytes: frostSigningPackage, + TaprootMerkleRoot: taprootMerkleRoot, + CoordinatorSignature: []byte{0x01}, // dummy; the verifier never authenticates + } + envelope, err := pkg.Marshal() + if err != nil { + t.Fatalf("cannot marshal test signing package: [%v]", err) + } + hash, err := pkg.BodyHash() + if err != nil { + t.Fatalf("cannot hash test signing package body: [%v]", err) + } + return envelope, hash[:] +} + +func testShareSubmissionEnvelope( + t *testing.T, + attemptContextHash [32]byte, + submitter uint32, + signingPackageHash []byte, + frostSignatureShare []byte, +) []byte { + t.Helper() + sub := &roast.ShareSubmission{ + AttemptContextHash: append([]byte(nil), attemptContextHash[:]...), + SubmitterIDValue: submitter, + CoordinatorIDValue: 1, + SigningPackageHash: signingPackageHash, + SignatureShare: frostSignatureShare, + SubmitterSignature: []byte{0x01}, // dummy + } + envelope, err := sub.Marshal() + if err != nil { + t.Fatalf("cannot marshal test share submission: [%v]", err) + } + return envelope +} + +func TestNewEngineRound2ShareVerifier_RejectsNilEngine(t *testing.T) { + _, err := NewEngineRound2ShareVerifier(nil, Round2ShareVerificationBinding{ + SessionID: "session-1", + }) + if err == nil { + t.Fatal("expected a nil engine to be rejected") + } +} + +func TestNewEngineRound2ShareVerifier_RejectsEmptySessionID(t *testing.T) { + _, err := NewEngineRound2ShareVerifier(&fakeShareVerifyingEngine{}, Round2ShareVerificationBinding{ + SessionID: "", + }) + if err == nil { + t.Fatal("expected an empty session id to be rejected") + } +} + +// The happy path for each native verdict, with a bound taproot root, asserting +// both the mapping and that the engine receives the unwrapped inner bytes, the +// widened member id, the bound session id, and the bound root. +func TestEngineRound2ShareVerifier_VerdictMappingWithRoot(t *testing.T) { + attempt := [32]byte{0xaa} + root := [32]byte{0xbb} + frostPackage := []byte{0xde, 0xad} + frostShare := []byte{0xbe, 0xef} + + tests := map[string]struct { + verdict NativeShareVerificationVerdict + expected roast.ShareVerificationResult + }{ + "valid": {NativeShareVerdictValid, roast.ShareValid}, + "invalid": {NativeShareVerdictInvalid, roast.ShareInvalid}, + "indeterminate": {NativeShareVerdictIndeterminate, roast.ShareIndeterminate}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + engine := &fakeShareVerifyingEngine{verdict: test.verdict} + verifier, err := NewEngineRound2ShareVerifier(engine, Round2ShareVerificationBinding{ + SessionID: "session-1", + AttemptContextHash: attempt, + TaprootMerkleRoot: &root, + }) + if err != nil { + t.Fatalf("unexpected constructor error: [%v]", err) + } + + pkgEnv, pkgHash := testSigningPackage(t, attempt, root[:], frostPackage) + shareEnv := testShareSubmissionEnvelope(t, attempt, 2, pkgHash, frostShare) + + result := verifier.VerifyRetainedShare(pkgEnv, shareEnv, 2) + if result != test.expected { + t.Fatalf("unexpected result\nexpected: [%v]\nactual: [%v]", test.expected, result) + } + if !engine.called { + t.Fatal("expected the engine to be called") + } + if engine.gotSessionID != "session-1" { + t.Fatalf("engine got wrong session id: [%s]", engine.gotSessionID) + } + if !bytes.Equal(engine.gotSigningPackage, frostPackage) { + t.Fatalf("engine got wrong inner signing package: [%x]", engine.gotSigningPackage) + } + if !bytes.Equal(engine.gotSignatureShare, frostShare) { + t.Fatalf("engine got wrong inner signature share: [%x]", engine.gotSignatureShare) + } + if engine.gotMemberIdentifier != 2 { + t.Fatalf("engine got wrong member id: [%d]", engine.gotMemberIdentifier) + } + if engine.gotTaprootRoot == nil || *engine.gotTaprootRoot != root { + t.Fatalf("engine got wrong taproot root: [%v]", engine.gotTaprootRoot) + } + }) + } +} + +// A key-path (nil root) verifier accepts a package carrying no root and passes a +// nil root to the engine. +func TestEngineRound2ShareVerifier_KeyPathNilRoot(t *testing.T) { + attempt := [32]byte{0xaa} + engine := &fakeShareVerifyingEngine{verdict: NativeShareVerdictValid} + verifier, err := NewEngineRound2ShareVerifier(engine, Round2ShareVerificationBinding{ + SessionID: "session-1", + AttemptContextHash: attempt, + TaprootMerkleRoot: nil, + }) + if err != nil { + t.Fatalf("unexpected constructor error: [%v]", err) + } + + pkgEnv, pkgHash := testSigningPackage(t, attempt, nil, []byte{0xde, 0xad}) + shareEnv := testShareSubmissionEnvelope(t, attempt, 2, pkgHash, []byte{0xbe, 0xef}) + + if result := verifier.VerifyRetainedShare(pkgEnv, shareEnv, 2); result != roast.ShareValid { + t.Fatalf("expected ShareValid, got [%v]", result) + } + if engine.gotTaprootRoot != nil { + t.Fatalf("expected a nil taproot root passed to the engine, got [%v]", engine.gotTaprootRoot) + } +} + +func TestEngineRound2ShareVerifier_EngineErrorIsIndeterminate(t *testing.T) { + attempt := [32]byte{0xaa} + engine := &fakeShareVerifyingEngine{ + verdict: NativeShareVerdictInvalid, // even a blame verdict must be ignored on error + err: errTestEngine, + } + verifier := mustVerifier(t, engine, "session-1", attempt, nil) + + pkgEnv, pkgHash := testSigningPackage(t, attempt, nil, []byte{0xde, 0xad}) + shareEnv := testShareSubmissionEnvelope(t, attempt, 2, pkgHash, []byte{0xbe, 0xef}) + + if result := verifier.VerifyRetainedShare(pkgEnv, shareEnv, 2); result != roast.ShareIndeterminate { + t.Fatalf("expected ShareIndeterminate on engine error, got [%v]", result) + } +} + +func TestEngineRound2ShareVerifier_FailClosedWithoutCallingEngine(t *testing.T) { + attempt := [32]byte{0xaa} + root := [32]byte{0xbb} + otherAttempt := [32]byte{0xcc} + otherRoot := [32]byte{0xdd} + frostPackage := []byte{0xde, 0xad} + frostShare := []byte{0xbe, 0xef} + + validPkgEnv, _ := testSigningPackage(t, attempt, root[:], frostPackage) + otherAttemptPkgEnv, _ := testSigningPackage(t, otherAttempt, root[:], frostPackage) + otherRootPkgEnv, _ := testSigningPackage(t, attempt, otherRoot[:], frostPackage) + + // A dummy package hash for the share in cases that fail closed BEFORE the + // share-commits-to-package check is reached. + dummyHash := make([]byte, 32) + + tests := map[string]struct { + bindRoot *[32]byte + pkgEnv []byte + shareEnv []byte + submitter group.MemberIndex + }{ + "submitter zero": { + bindRoot: &root, + pkgEnv: validPkgEnv, + shareEnv: testShareSubmissionEnvelope(t, attempt, 2, dummyHash, frostShare), + submitter: 0, + }, + "unparseable signing-package envelope": { + bindRoot: &root, + pkgEnv: []byte("not a signing package envelope"), + shareEnv: testShareSubmissionEnvelope(t, attempt, 2, dummyHash, frostShare), + submitter: 2, + }, + "attempt-context mismatch": { + bindRoot: &root, + pkgEnv: otherAttemptPkgEnv, + shareEnv: testShareSubmissionEnvelope(t, attempt, 2, dummyHash, frostShare), + submitter: 2, + }, + "taproot root mismatch": { + bindRoot: &root, + pkgEnv: otherRootPkgEnv, + shareEnv: testShareSubmissionEnvelope(t, attempt, 2, dummyHash, frostShare), + submitter: 2, + }, + "key-path verifier rejects a rooted package": { + bindRoot: nil, + pkgEnv: validPkgEnv, + shareEnv: testShareSubmissionEnvelope(t, attempt, 2, dummyHash, frostShare), + submitter: 2, + }, + "unparseable share envelope": { + bindRoot: &root, + pkgEnv: validPkgEnv, + shareEnv: []byte("not a share submission envelope"), + submitter: 2, + }, + "share submitter id mismatch": { + bindRoot: &root, + pkgEnv: validPkgEnv, + shareEnv: testShareSubmissionEnvelope(t, attempt, 3, dummyHash, frostShare), // envelope says member 3 + submitter: 2, // adjudicating member 2 + }, + "share commits to a different package": { + bindRoot: &root, + pkgEnv: validPkgEnv, + shareEnv: testShareSubmissionEnvelope(t, attempt, 2, bytes.Repeat([]byte{0xee}, 32), frostShare), + submitter: 2, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // A valid verdict would map to ShareValid IF the engine were called - + // so a ShareIndeterminate result also proves the engine was bypassed. + engine := &fakeShareVerifyingEngine{verdict: NativeShareVerdictValid} + verifier := mustVerifier(t, engine, "session-1", attempt, test.bindRoot) + + result := verifier.VerifyRetainedShare(test.pkgEnv, test.shareEnv, test.submitter) + if result != roast.ShareIndeterminate { + t.Fatalf("expected ShareIndeterminate, got [%v]", result) + } + if engine.called { + t.Fatal("engine must not be called when context fails closed before verification") + } + }) + } +} + +// The constructor must copy the bound taproot root so a later mutation of the +// caller's array cannot change the verifier's root binding. +func TestEngineRound2ShareVerifier_ConstructorCopiesTaprootRoot(t *testing.T) { + attempt := [32]byte{0xaa} + root := [32]byte{0xbb} + originalRoot := root // value copy of the original bytes + + engine := &fakeShareVerifyingEngine{verdict: NativeShareVerdictValid} + verifier, err := NewEngineRound2ShareVerifier(engine, Round2ShareVerificationBinding{ + SessionID: "session-1", + AttemptContextHash: attempt, + TaprootMerkleRoot: &root, + }) + if err != nil { + t.Fatalf("unexpected constructor error: [%v]", err) + } + + // Mutate the caller's array AFTER construction. If the verifier retained the + // caller's pointer instead of copying, its root would now differ from + // originalRoot and the original-root package below would (wrongly) mismatch. + root[0] = 0xff + + pkgEnv, pkgHash := testSigningPackage(t, attempt, originalRoot[:], []byte{0xde, 0xad}) + shareEnv := testShareSubmissionEnvelope(t, attempt, 2, pkgHash, []byte{0xbe, 0xef}) + + if result := verifier.VerifyRetainedShare(pkgEnv, shareEnv, 2); result != roast.ShareValid { + t.Fatalf("expected ShareValid (verifier root unaffected by caller mutation), got [%v]", result) + } + if !engine.called { + t.Fatal("expected the engine to be called against the copied root") + } +} + +func mustVerifier( + t *testing.T, + engine Round2ShareVerifyingEngine, + sessionID string, + attemptContextHash [32]byte, + taprootMerkleRoot *[32]byte, +) *EngineRound2ShareVerifier { + t.Helper() + verifier, err := NewEngineRound2ShareVerifier(engine, Round2ShareVerificationBinding{ + SessionID: sessionID, + AttemptContextHash: attemptContextHash, + TaprootMerkleRoot: taprootMerkleRoot, + }) + if err != nil { + t.Fatalf("unexpected constructor error: [%v]", err) + } + return verifier +} diff --git a/pkg/frost/signing/signing.go b/pkg/frost/signing/signing.go new file mode 100644 index 0000000000..3ea4ab3a63 --- /dev/null +++ b/pkg/frost/signing/signing.go @@ -0,0 +1,121 @@ +package signing + +import ( + "context" + "fmt" + "math/big" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +// Execute runs signing and returns a Schnorr-shaped 64-byte signature. +// +// Transitional note: +// This implementation currently delegates group coordination and cryptographic +// operations to the legacy tECDSA engine and converts the resulting (R, S) +// components to the fixed-width Schnorr signature container. +func Execute( + ctx context.Context, + logger log.StandardLogger, + message *big.Int, + sessionID string, + memberIndex group.MemberIndex, + privateKeyShare *tecdsa.PrivateKeyShare, + groupSize int, + dishonestThreshold int, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + attempt *Attempt, +) (*Result, error) { + request := &Request{ + Message: message, + SessionID: sessionID, + MemberIndex: memberIndex, + SignerMaterial: privateKeyShare, + PrivateKeyShare: privateKeyShare, + GroupSize: groupSize, + DishonestThreshold: dishonestThreshold, + Channel: channel, + MembershipValidator: membershipValidator, + Attempt: attempt, + } + + return ExecuteRequest(ctx, logger, request) +} + +// ExecuteRequest runs signing using a fully-populated request object. +// It clones mutable request metadata needed for execution safety. +func ExecuteRequest( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + clonedRequest := *request + clonedRequest.Attempt = cloneAttempt(request.Attempt) + + return currentExecutionBackend().Execute( + ctx, + logger, + &clonedRequest, + ) +} + +// RegisterUnmarshallers initializes all required message unmarshallers. +// For now, signing transport message formats are delegated to the legacy +// engine implementation. +func RegisterUnmarshallers(channel net.BroadcastChannel) { + currentExecutionBackend().RegisterUnmarshallers(channel) +} + +// FromTECDSASignature maps a legacy signature to the fixed-width Schnorr +// signature container by preserving R/S values and dropping RecoveryID. +func FromTECDSASignature(signature *tecdsa.Signature) (*frost.Signature, error) { + if signature == nil { + return nil, fmt.Errorf("signature is nil") + } + + if signature.R == nil || signature.S == nil { + return nil, fmt.Errorf("signature components cannot be nil") + } + + if signature.R.Sign() < 0 || signature.S.Sign() < 0 { + return nil, fmt.Errorf("signature components cannot be negative") + } + + rBytes := signature.R.Bytes() + sBytes := signature.S.Bytes() + + if len(rBytes) > frost.SignatureComponentSize { + return nil, fmt.Errorf( + "R component too large: [%d] bytes", + len(rBytes), + ) + } + + if len(sBytes) > frost.SignatureComponentSize { + return nil, fmt.Errorf( + "S component too large: [%d] bytes", + len(sBytes), + ) + } + + frostSignature := &frost.Signature{} + copy( + frostSignature.R[frost.SignatureComponentSize-len(rBytes):], + rBytes, + ) + copy( + frostSignature.S[frost.SignatureComponentSize-len(sBytes):], + sBytes, + ) + + return frostSignature, nil +} diff --git a/pkg/frost/signing/signing_test.go b/pkg/frost/signing/signing_test.go new file mode 100644 index 0000000000..f54ff8cfe4 --- /dev/null +++ b/pkg/frost/signing/signing_test.go @@ -0,0 +1,183 @@ +package signing + +import ( + "context" + "math/big" + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestFromTECDSASignature(t *testing.T) { + signature := &tecdsa.Signature{ + R: big.NewInt(0x1234), + S: big.NewInt(0xabcd), + } + + result, err := FromTECDSASignature(signature) + if err != nil { + t.Fatalf("conversion failed: [%v]", err) + } + + if result.R[30] != 0x12 || result.R[31] != 0x34 { + t.Fatalf("unexpected R component bytes") + } + + if result.S[30] != 0xab || result.S[31] != 0xcd { + t.Fatalf("unexpected S component bytes") + } +} + +func TestFromTECDSASignature_ValidationErrors(t *testing.T) { + testData := []struct { + name string + signature *tecdsa.Signature + }{ + { + name: "nil signature", + signature: nil, + }, + { + name: "nil R", + signature: &tecdsa.Signature{ + R: nil, + S: big.NewInt(1), + }, + }, + { + name: "nil S", + signature: &tecdsa.Signature{ + R: big.NewInt(1), + S: nil, + }, + }, + { + name: "negative R", + signature: &tecdsa.Signature{ + R: big.NewInt(-1), + S: big.NewInt(1), + }, + }, + { + name: "negative S", + signature: &tecdsa.Signature{ + R: big.NewInt(1), + S: big.NewInt(-1), + }, + }, + } + + for _, tc := range testData { + t.Run(tc.name, func(t *testing.T) { + _, err := FromTECDSASignature(tc.signature) + if err == nil { + t.Fatal("expected conversion error") + } + }) + } +} + +func TestExecuteRequest_NilRequest(t *testing.T) { + _, err := ExecuteRequest(context.Background(), nil, nil) + if err == nil { + t.Fatal("expected request validation error") + } +} + +func TestExecuteRequest_ClonesAttempt(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + backend := &mockExecutionBackend{ + name: "mock", + result: &Result{}, + } + + if err := SetExecutionBackend(backend); err != nil { + t.Fatalf("unexpected backend setup error: [%v]", err) + } + + request := &Request{ + Attempt: &Attempt{ + Number: 2, + CoordinatorMemberIndex: 3, + IncludedMembersIndexes: []group.MemberIndex{1, 3, 5}, + ExcludedMembersIndexes: []group.MemberIndex{2, 4}, + }, + } + + if _, err := ExecuteRequest(context.Background(), nil, request); err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if backend.lastRequest == request { + t.Fatal("expected request clone before backend execution") + } + + if backend.lastRequest.Attempt == request.Attempt { + t.Fatal("expected attempt clone before backend execution") + } + + if !reflect.DeepEqual(backend.lastRequest.Attempt, request.Attempt) { + t.Fatalf( + "unexpected attempt clone\nexpected: [%+v]\nactual: [%+v]", + request.Attempt, + backend.lastRequest.Attempt, + ) + } +} + +func TestExecute_PopulatesSignerMaterialAndLegacyAlias(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + backend := &mockExecutionBackend{ + name: "mock", + result: &Result{}, + } + + if err := SetExecutionBackend(backend); err != nil { + t.Fatalf("unexpected backend setup error: [%v]", err) + } + + privateKeyShare := new(tecdsa.PrivateKeyShare) + + _, err := Execute( + context.Background(), + nil, + big.NewInt(42), + "session-id", + group.MemberIndex(7), + privateKeyShare, + 10, + 3, + nil, + nil, + nil, + ) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if backend.lastRequest == nil { + t.Fatal("expected backend request") + } + + if backend.lastRequest.SignerMaterial != privateKeyShare { + t.Fatalf( + "unexpected signer material\nexpected: [%v]\nactual: [%v]", + privateKeyShare, + backend.lastRequest.SignerMaterial, + ) + } + + if backend.lastRequest.PrivateKeyShare != privateKeyShare { + t.Fatalf( + "unexpected legacy private key share alias\nexpected: [%v]\nactual: [%v]", + privateKeyShare, + backend.lastRequest.PrivateKeyShare, + ) + } +} diff --git a/pkg/frost/signing/unsupported_uniffi_v2_test.go b/pkg/frost/signing/unsupported_uniffi_v2_test.go new file mode 100644 index 0000000000..3cc65680ab --- /dev/null +++ b/pkg/frost/signing/unsupported_uniffi_v2_test.go @@ -0,0 +1,32 @@ +//go:build frost_native + +package signing + +import "encoding/json" + +func unsupportedUniFFIV2Payload(t testFataler, verifyingKey string) []byte { + t.Helper() + + payload, err := json.Marshal(&struct { + KeyPackage *NativeFROSTKeyPackage `json:"keyPackage"` + PublicKeyPackage *NativeFROSTPublicKeyPackage `json:"publicKeyPackage"` + }{ + KeyPackage: &NativeFROSTKeyPackage{ + Identifier: "id-1", + Data: []byte{0x01}, + }, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingKey: verifyingKey, + }, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + return payload +} + +type testFataler interface { + Helper() + Fatalf(format string, args ...any) +} diff --git a/pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json b/pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json new file mode 100644 index 0000000000..841e66180d --- /dev/null +++ b/pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json @@ -0,0 +1,71 @@ +{ + "name": "wallet-pubkey-hash-derivation-vectors", + "version": "v1", + "description": "Cross-repo test vectors for HASH160-based wallet pubkey hash derivation. Both the tbtc bridge contracts (tlabs-xyz/tbtc) and the keep-core FROST protocol (threshold-network/keep-core) must derive the same 20-byte alias from the same input. Drift between the two derivations silently breaks the bridge-protocol identity contract for any wallet whose canonical identity is established cross-repo. This fixture is the tripwire: identical JSON is checked into both repos; each side has a test that reads it and asserts its own derivation function reproduces the expected output. If the two sides diverge, at least one repo's test fails.", + "ecdsa_legacy": [ + { + "name": "secp256k1 generator point (compressed, even y)", + "input": { + "compressedPubKey": "0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + }, + "expected": { + "walletPubKeyHash": "0x751e76e8199196d454941c45d1b3a323f1433bd6" + }, + "note": "Bitcoin's classic generator-point compressed pubkey, well-known HASH160. Corresponds to mainnet address 1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2." + }, + { + "name": "Near-zero scalar pubkey (compressed, even y)", + "input": { + "compressedPubKey": "0x02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + }, + "expected": { + "walletPubKeyHash": "0x06afd46bcdfd22ef94ac122aa11f241244a37ecc" + } + }, + { + "name": "tBTC fixture pubkey (matches contracts/tbtc-v2/test/data/ecdsa.ts)", + "input": { + "compressedPubKey": "0x0250863ad64a87ae8a2fe83c1af1a8403cb53f53e486d8511dad8a04887e5b2352" + }, + "expected": { + "walletPubKeyHash": "0xf54a5851e9372b87810a8e60cdd2e7cfd80b6e31" + }, + "note": "Cross-validates against the existing pubKeyHash160 constant in the tBTC ECDSA test fixture data, ensuring this vector matches what the tBTC test suite already pins." + } + ], + "frost_p2tr": [ + { + "name": "Representative FROST x-only output key", + "input": { + "xOnlyOutputKey": "0xb1de1afa17e1cbb20d8a4f8e54f8a55fbf5c8d2da9e1c6c4d1f0c7b3a2e5d4c8" + }, + "expected": { + "walletPubKeyHash": "0xac756e3ad02acf580218a3ba2232b081906be776" + }, + "note": "The high 12 bytes are non-zero, matching the native-shape constraint required by the FROST wallet registration entry point (see PR #431). The expected pubKeyHash is HASH160(0x02 || xOnlyOutputKey)." + }, + { + "name": "All-ones x-only key (regression case)", + "input": { + "xOnlyOutputKey": "0x0101010101010101010101010101010101010101010101010101010101010101" + }, + "expected": { + "walletPubKeyHash": "0x9b596d772a3bfe0335f36c38357f026221212c90" + } + }, + { + "name": "All-max x-only key (boundary case)", + "input": { + "xOnlyOutputKey": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "expected": { + "walletPubKeyHash": "0x2914980c04dec23ab03cfcd610adf39d62d7c5fb" + } + } + ], + "drift_check": { + "tbtc_path": "docs/test-vectors/wallet-pubkey-hash-derivation-vectors-v1.json", + "keep_core_path": "pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json", + "rule": "The byte-identical JSON file must exist at both paths. A future CI check should compare file hashes between repos; for now, the per-repo tests catch derivation drift even if the JSON itself drifts (the harder failure mode is silently identical JSON with different implementations underneath)." + } +} diff --git a/pkg/frost/types.go b/pkg/frost/types.go new file mode 100644 index 0000000000..02399033b8 --- /dev/null +++ b/pkg/frost/types.go @@ -0,0 +1,96 @@ +package frost + +import ( + "encoding/hex" + "fmt" + + "github.com/btcsuite/btcutil" +) + +const ( + // OutputKeySize is the byte length of a Taproot x-only output key. + OutputKeySize = 32 + // SignatureComponentSize is the byte length of each Schnorr signature part. + SignatureComponentSize = 32 + // SignatureSize is the full serialized BIP-340 signature length. + SignatureSize = 2 * SignatureComponentSize +) + +// OutputKey is a Taproot x-only output key used by BIP-340/341. +type OutputKey [OutputKeySize]byte + +// WalletPublicKeyHashCompatibilityAlias computes the 20-byte compatibility +// alias from a Taproot output key: +// HASH160(0x02 || xOnlyOutputKey). +// The x-only output key is assumed to already use BIP-340's even-Y convention; +// callers must not strip a compressed odd-Y key without first applying the +// BIP-340 negation rule upstream. +func WalletPublicKeyHashCompatibilityAlias(outputKey OutputKey) [20]byte { + serialized := make([]byte, 0, 1+OutputKeySize) + serialized = append(serialized, byte(0x02)) + serialized = append(serialized, outputKey[:]...) + + hash := btcutil.Hash160(serialized) + + var result [20]byte + copy(result[:], hash) + + return result +} + +// Signature is a 64-byte BIP-340 Schnorr signature split into its two +// 32-byte components: R (x-coordinate nonce commitment) and S (scalar). +type Signature struct { + R [SignatureComponentSize]byte + S [SignatureComponentSize]byte +} + +// Serialize concatenates signature components into a 64-byte value. +func (s *Signature) Serialize() [2 * SignatureComponentSize]byte { + var result [SignatureSize]byte + copy(result[0:SignatureComponentSize], s.R[:]) + copy(result[SignatureComponentSize:], s.S[:]) + return result +} + +// Marshal encodes signature into a 64-byte canonical form. +func (s *Signature) Marshal() ([]byte, error) { + serialized := s.Serialize() + result := make([]byte, SignatureSize) + copy(result, serialized[:]) + return result, nil +} + +// Unmarshal decodes signature from a 64-byte canonical form. +func (s *Signature) Unmarshal(data []byte) error { + if len(data) != SignatureSize { + return fmt.Errorf( + "invalid signature length: [%d], expected [%d]", + len(data), + SignatureSize, + ) + } + + copy(s.R[:], data[:SignatureComponentSize]) + copy(s.S[:], data[SignatureComponentSize:]) + + return nil +} + +// Equals determines whether two signatures are equal. +func (s *Signature) Equals(other *Signature) bool { + if s == nil || other == nil { + return s == other + } + + return s.R == other.R && s.S == other.S +} + +// String returns a hex representation useful in logs. +func (s *Signature) String() string { + serialized := s.Serialize() + return fmt.Sprintf("R: 0x%s, S: 0x%s", + hex.EncodeToString(serialized[0:SignatureComponentSize]), + hex.EncodeToString(serialized[SignatureComponentSize:]), + ) +} diff --git a/pkg/frost/types_test.go b/pkg/frost/types_test.go new file mode 100644 index 0000000000..ef3cbdb520 --- /dev/null +++ b/pkg/frost/types_test.go @@ -0,0 +1,94 @@ +package frost + +import ( + "encoding/hex" + "testing" +) + +func TestWalletPublicKeyHashCompatibilityAlias(t *testing.T) { + outputKeyHex := "11223344556677889900aabbccddeeff00112233445566778899aabbccddeeff" + expectedAliasHex := "c2a27a88d8d03e271e8edc556923e9398619f17c" + + outputKeyBytes, err := hex.DecodeString(outputKeyHex) + if err != nil { + t.Fatalf("failed to decode output key: [%v]", err) + } + + var outputKey OutputKey + copy(outputKey[:], outputKeyBytes) + + actualAlias := WalletPublicKeyHashCompatibilityAlias(outputKey) + actualAliasHex := hex.EncodeToString(actualAlias[:]) + + if actualAliasHex != expectedAliasHex { + t.Fatalf( + "unexpected alias\nactual: [%s]\nexpected: [%s]", + actualAliasHex, + expectedAliasHex, + ) + } +} + +func TestSignatureSerialize(t *testing.T) { + signature := &Signature{} + signature.R = [SignatureComponentSize]byte{0x01, 0x02, 0x03} + signature.S = [SignatureComponentSize]byte{0xaa, 0xbb, 0xcc} + + serialized := signature.Serialize() + + if serialized[0] != 0x01 || serialized[1] != 0x02 || serialized[2] != 0x03 { + t.Fatalf("unexpected R serialization") + } + + if serialized[SignatureComponentSize] != 0xaa || + serialized[SignatureComponentSize+1] != 0xbb || + serialized[SignatureComponentSize+2] != 0xcc { + t.Fatalf("unexpected S serialization") + } +} + +func TestSignatureMarshalUnmarshal(t *testing.T) { + original := &Signature{ + R: [SignatureComponentSize]byte{0x11, 0x22, 0x33}, + S: [SignatureComponentSize]byte{0xaa, 0xbb, 0xcc}, + } + + marshaled, err := original.Marshal() + if err != nil { + t.Fatalf("marshal failed: [%v]", err) + } + + decoded := &Signature{} + if err := decoded.Unmarshal(marshaled); err != nil { + t.Fatalf("unmarshal failed: [%v]", err) + } + + if !original.Equals(decoded) { + t.Fatalf("decoded signature does not match original") + } +} + +func TestSignatureUnmarshal_InvalidLength(t *testing.T) { + signature := &Signature{} + err := signature.Unmarshal([]byte{0x01, 0x02, 0x03}) + if err == nil { + t.Fatal("expected invalid-length unmarshal error") + } +} + +func TestSignatureString(t *testing.T) { + signature := &Signature{ + R: [SignatureComponentSize]byte{0x01, 0x02}, + S: [SignatureComponentSize]byte{0x0a, 0x0b}, + } + + expected := "R: 0x0102000000000000000000000000000000000000000000000000000000000000, S: 0x0a0b000000000000000000000000000000000000000000000000000000000000" + + if signature.String() != expected { + t.Fatalf( + "unexpected signature string\nactual: [%s]\nexpected: [%s]", + signature.String(), + expected, + ) + } +} diff --git a/pkg/frost/wallet_pubkey_hash_derivation_vectors_test.go b/pkg/frost/wallet_pubkey_hash_derivation_vectors_test.go new file mode 100644 index 0000000000..0946d08c6b --- /dev/null +++ b/pkg/frost/wallet_pubkey_hash_derivation_vectors_test.go @@ -0,0 +1,263 @@ +package frost + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "golang.org/x/crypto/ripemd160" //nolint:staticcheck // RIPEMD-160 is intentional for the HASH160 derivation. +) + +// Cross-repo derivation fixture (also checked into the tbtc bridge repo +// at docs/test-vectors/wallet-pubkey-hash-derivation-vectors-v1.json). +// Each repo's test must reproduce the expected output from the same +// input; if either side drifts from the other, at least one repo's +// test fails. Drift between bridge and keep-core silently breaks the +// wallet identity contract for any wallet whose canonical identity is +// established cross-repo (in particular, FROST wallets registered via +// the FROST WalletRegistry will use this derivation). +// +// Path constants follow two different conventions intentionally: +// +// - walletPubKeyHashDerivationVectorsTestPath: package-relative, +// used by os.ReadFile() because `go test ./pkg/frost` runs with +// pkg/frost as the working directory. This is the standard Go +// testdata convention. +// +// - walletPubKeyHashDerivationVectorsRepoPath: repo-root-relative, +// used to compare against fixture.DriftCheck.KeepCorePath +// (which declares the canonical location for cross-repo sync +// tooling). This is what a cross-repo diff tool would use. +// +// The two MUST refer to the same file; the TestDriftCheckMetadata +// assertions verify the fixture's self-declared path matches the +// repo-relative constant exactly. +const ( + walletPubKeyHashDerivationVectorsTestPath = "testdata/wallet-pubkey-hash-derivation-vectors-v1.json" + walletPubKeyHashDerivationVectorsRepoPath = "pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json" +) + +type ecdsaVector struct { + Name string `json:"name"` + Input struct { + CompressedPubKey string `json:"compressedPubKey"` + } `json:"input"` + Expected struct { + WalletPubKeyHash string `json:"walletPubKeyHash"` + } `json:"expected"` + Note string `json:"note,omitempty"` +} + +type frostVector struct { + Name string `json:"name"` + Input struct { + XOnlyOutputKey string `json:"xOnlyOutputKey"` + } `json:"input"` + Expected struct { + WalletPubKeyHash string `json:"walletPubKeyHash"` + } `json:"expected"` + Note string `json:"note,omitempty"` +} + +type derivationFixture struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + EcdsaLegacy []ecdsaVector `json:"ecdsa_legacy"` + FrostP2tr []frostVector `json:"frost_p2tr"` + DriftCheck struct { + TbtcPath string `json:"tbtc_path"` + KeepCorePath string `json:"keep_core_path"` + Rule string `json:"rule"` + } `json:"drift_check"` +} + +func loadDerivationFixture(t *testing.T) derivationFixture { + t.Helper() + + data, err := os.ReadFile(walletPubKeyHashDerivationVectorsTestPath) + if err != nil { + t.Fatalf("fixture read: %v", err) + } + var fixture derivationFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("fixture parse: %v", err) + } + if fixture.Version != "v1" { + t.Fatalf( + "fixture schemaVersion drift: got %q, expected %q -- both repos must update together", + fixture.Version, + "v1", + ) + } + return fixture +} + +// TestFrostWalletPubKeyHashDerivationVectors checks that +// frost.WalletPublicKeyHashCompatibilityAlias produces the expected +// 20-byte HASH160(0x02 || xOnlyOutputKey) for every FROST vector in +// the shared cross-repo fixture. The tbtc bridge runs the equivalent +// check against its own derivation (BitcoinTx.deriveWalletPubKeyHash- +// FromXOnly); if either side drifts, the wallet identity contract +// between the bridge and the protocol silently breaks for any FROST +// wallet whose canonical identity is established cross-repo. +func TestFrostWalletPubKeyHashDerivationVectors(t *testing.T) { + fixture := loadDerivationFixture(t) + + if len(fixture.FrostP2tr) == 0 { + t.Fatal("fixture must contain at least one FROST vector") + } + + for _, vector := range fixture.FrostP2tr { + vector := vector + t.Run(vector.Name, func(t *testing.T) { + xOnlyBytes, err := hex.DecodeString( + strings.TrimPrefix(vector.Input.XOnlyOutputKey, "0x"), + ) + if err != nil { + t.Fatalf("decode xOnlyOutputKey: %v", err) + } + if len(xOnlyBytes) != OutputKeySize { + t.Fatalf( + "xOnlyOutputKey length: got %d, expected %d", + len(xOnlyBytes), + OutputKeySize, + ) + } + + var outputKey OutputKey + copy(outputKey[:], xOnlyBytes) + + alias := WalletPublicKeyHashCompatibilityAlias(outputKey) + got := "0x" + hex.EncodeToString(alias[:]) + want := strings.ToLower(vector.Expected.WalletPubKeyHash) + + if got != want { + t.Fatalf( + "derivation drift for vector %q:\n got: %s\n want: %s\n"+ + "\nThis test enforces the cross-repo contract that\n"+ + "frost.WalletPublicKeyHashCompatibilityAlias and the\n"+ + "tbtc bridge's BitcoinTx.deriveWalletPubKeyHashFromXOnly\n"+ + "produce the same 20-byte alias for the same input.\n"+ + "If this test fails, also expect the tbtc-side test to\n"+ + "fail unless the JSON fixture itself has drifted.", + vector.Name, + got, + want, + ) + } + }) + } +} + +// TestEcdsaCompressedPubKeyHash160Vectors checks the legacy ECDSA +// derivation path: HASH160 of the compressed pubkey. The tbtc bridge +// performs this implicitly during registerNewWallet (compress then +// hash160). The off-chain operator tooling that produces deposit +// scripts performs the same derivation; this test pins the algorithm +// from the keep-core side using the same vectors the bridge pins on +// its side. +func TestEcdsaCompressedPubKeyHash160Vectors(t *testing.T) { + fixture := loadDerivationFixture(t) + + if len(fixture.EcdsaLegacy) == 0 { + t.Fatal("fixture must contain at least one ECDSA vector") + } + + for _, vector := range fixture.EcdsaLegacy { + vector := vector + t.Run(vector.Name, func(t *testing.T) { + compressed, err := hex.DecodeString( + strings.TrimPrefix(vector.Input.CompressedPubKey, "0x"), + ) + if err != nil { + t.Fatalf("decode compressedPubKey: %v", err) + } + + got := "0x" + hex.EncodeToString(hash160(compressed)) + want := strings.ToLower(vector.Expected.WalletPubKeyHash) + + if got != want { + t.Fatalf( + "HASH160 drift for vector %q:\n got: %s\n want: %s", + vector.Name, + got, + want, + ) + } + }) + } +} + +// TestDriftCheckMetadata asserts the fixture declares the tbtc mirror +// path and a non-empty drift rule. A future CI sync check can use +// these fields to compare files between repos. The fixture's +// keep_core_path is repo-root-relative by convention; the package- +// relative testdata constant used by os.ReadFile() is a separate +// representation of the same file. +func TestDriftCheckMetadata(t *testing.T) { + fixture := loadDerivationFixture(t) + + if fixture.DriftCheck.TbtcPath != "docs/test-vectors/wallet-pubkey-hash-derivation-vectors-v1.json" { + t.Errorf( + "drift_check.tbtc_path drift: got %q", + fixture.DriftCheck.TbtcPath, + ) + } + if fixture.DriftCheck.KeepCorePath != walletPubKeyHashDerivationVectorsRepoPath { + t.Errorf( + "drift_check.keep_core_path drift: fixture says %q, repo convention is %q", + fixture.DriftCheck.KeepCorePath, + walletPubKeyHashDerivationVectorsRepoPath, + ) + } + if fixture.DriftCheck.Rule == "" { + t.Error("drift_check.rule must be non-empty") + } +} + +// TestFixtureFileShouldExistAtMirrorPath documents the convention that +// the file lives at the path the fixture self-declares. Since the +// fixture's keep_core_path is repo-root-relative but `go test +// ./pkg/frost` runs with pkg/frost as the working directory, the path +// is resolved relative to the repo root by walking up from this test +// file's location. +func TestFixtureFileShouldExistAtMirrorPath(t *testing.T) { + fixture := loadDerivationFixture(t) + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller: cannot locate test source file") + } + // thisFile points at pkg/frost/wallet_pubkey_hash_derivation_vectors_test.go + // repo root is two directories up. + repoRoot := filepath.Clean( + filepath.Join(filepath.Dir(thisFile), "..", ".."), + ) + abs := filepath.Join(repoRoot, fixture.DriftCheck.KeepCorePath) + if _, err := os.Stat(abs); err != nil { + t.Fatalf( + "fixture self-declares it lives at %q (resolved to %q) but the file is not there: %v", + fixture.DriftCheck.KeepCorePath, + abs, + err, + ) + } +} + +// hash160 reproduces Bitcoin's HASH160 (RIPEMD160(SHA256(x))) using +// the same primitive frost.WalletPublicKeyHashCompatibilityAlias +// invokes via btcutil.Hash160. We compute it directly here so the +// ECDSA test is self-contained and doesn't pull in btcutil for a one- +// liner. +func hash160(b []byte) []byte { + sha := sha256.Sum256(b) + rip := ripemd160.New() + rip.Write(sha[:]) + return rip.Sum(nil) +} diff --git a/pkg/generator/scheduler.go b/pkg/generator/scheduler.go index 73c9d25350..014f7b1395 100644 --- a/pkg/generator/scheduler.go +++ b/pkg/generator/scheduler.go @@ -112,10 +112,16 @@ func (s *Scheduler) resume() { // This function should be executed only be the Scheduler and when the // workMutex is locked. func (s *Scheduler) startWorker(workerFn func(context.Context)) { + // #nosec G118 -- The cancel function is retained in s.stops and invoked + // when the scheduler stops workers. ctx, cancelFn := context.WithCancel(context.Background()) - s.stops = append(s.stops, cancelFn) + s.stops = append(s.stops, func() { + cancelFn() + }) go func() { + defer cancelFn() + for { select { case <-ctx.Done(): diff --git a/pkg/maintainer/spv/bitcoin_chain_test.go b/pkg/maintainer/spv/bitcoin_chain_test.go index 2f790bf11f..447c2ca005 100644 --- a/pkg/maintainer/spv/bitcoin_chain_test.go +++ b/pkg/maintainer/spv/bitcoin_chain_test.go @@ -148,6 +148,38 @@ func (lbc *localBitcoinChain) GetTransactionsForPublicKeyHash( return matchingTransactions, nil } +func (lbc *localBitcoinChain) GetTransactionsForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, + limit int, +) ([]*bitcoin.Transaction, error) { + lbc.mutex.Lock() + defer lbc.mutex.Unlock() + + matchingTransactions := make([]*bitcoin.Transaction, 0) + + for _, transaction := range lbc.transactions { + transactionMatches := false + for _, output := range transaction.Outputs { + for _, publicKeyScript := range publicKeyScripts { + if bytes.Equal(output.PublicKeyScript, publicKeyScript) { + matchingTransactions = append(matchingTransactions, transaction) + transactionMatches = true + break + } + } + if transactionMatches { + break + } + } + } + + if len(matchingTransactions) > limit { + return matchingTransactions[len(matchingTransactions)-limit:], nil + } + + return matchingTransactions, nil +} + func (lbc *localBitcoinChain) GetTxHashesForPublicKeyHash( publicKeyHash [20]byte, ) ([]bitcoin.Hash, error) { diff --git a/pkg/maintainer/spv/chain.go b/pkg/maintainer/spv/chain.go index c9e060e9bf..a3444fd349 100644 --- a/pkg/maintainer/spv/chain.go +++ b/pkg/maintainer/spv/chain.go @@ -32,6 +32,10 @@ type Chain interface { walletPublicKeyHash [20]byte, ) (*tbtc.WalletChainData, error) + // WalletPublicKeyHashForWalletID resolves the canonical wallet ID to the + // wallet public key hash used by Bridge mappings. + WalletPublicKeyHashForWalletID(walletID [32]byte) ([20]byte, error) + // ComputeMainUtxoHash computes the hash of the provided main UTXO // according to the on-chain Bridge rules. ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 2a0bcc0a89..99cb4b4013 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -163,6 +163,28 @@ func (lc *localChain) GetWallet(walletPublicKeyHash [20]byte) ( return walletChainData, nil } +func (lc *localChain) WalletPublicKeyHashForWalletID( + walletID [32]byte, +) ([20]byte, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + for walletPublicKeyHash, walletChainData := range lc.wallets { + if walletChainData.WalletID == walletID || + walletChainData.EcdsaWalletID == walletID { + return walletPublicKeyHash, nil + } + } + + legacyWalletPublicKeyHash, ok := + tbtc.WalletPublicKeyHashFromLegacyWalletID(walletID) + if ok { + return legacyWalletPublicKeyHash, nil + } + + return [20]byte{}, fmt.Errorf("no wallet for given wallet ID") +} + func (lc *localChain) setWallet( walletPublicKeyHash [20]byte, walletChainData *tbtc.WalletChainData, diff --git a/pkg/maintainer/spv/deposit_sweep.go b/pkg/maintainer/spv/deposit_sweep.go index 2b0b8a5f77..4e1d52eb8f 100644 --- a/pkg/maintainer/spv/deposit_sweep.go +++ b/pkg/maintainer/spv/deposit_sweep.go @@ -6,7 +6,6 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" - "github.com/btcsuite/btcd/txscript" "github.com/ethereum/go-ethereum/common" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" @@ -156,13 +155,25 @@ func parseDepositSweepTransactionInputs( publicKeyScript := previousTransaction.Outputs[outpointIndex].PublicKeyScript value := previousTransaction.Outputs[outpointIndex].Value - scriptClass := txscript.GetScriptClass(publicKeyScript) + scriptType := bitcoin.GetScriptType(publicKeyScript) - if scriptClass == txscript.PubKeyHashTy || - scriptClass == txscript.WitnessV0PubKeyHashTy { - // The input is P2PKH or P2WPKH, so we found main UTXO. There should - // be at most one main UTXO. If any input of this kind has already - // been found, report an error. + deposit, found, err := spvChain.GetDepositRequest( + outpointTransactionHash, + outpointIndex, + ) + if err != nil { + return bitcoin.UnspentTransactionOutput{}, common.Address{}, fmt.Errorf( + "failed to get deposit request: [%v]", + err, + ) + } + + if !found && (scriptType == bitcoin.P2PKHScript || + scriptType == bitcoin.P2WPKHScript || + scriptType == bitcoin.P2TRScript) { + // The input is a direct wallet UTXO, so we found main UTXO. + // There should be at most one main UTXO. If any input of this + // kind has already been found, report an error. if mainUTXO == nil { mainUTXO = &bitcoin.UnspentTransactionOutput{ Outpoint: &bitcoin.TransactionOutpoint{ @@ -177,31 +188,13 @@ func parseDepositSweepTransactionInputs( "inputs", ) } - } else if scriptClass == txscript.ScriptHashTy || - scriptClass == txscript.WitnessV0ScriptHashTy { - // The input is P2SH or P2WSH, so we found a deposit input. All + } else if found && (scriptType == bitcoin.P2SHScript || + scriptType == bitcoin.P2WSHScript || + scriptType == bitcoin.P2TRScript) { + // The input is a deposit input. All // the deposits should have the same vault set or no vault at all. // If the vault if different than the vault from any previous // deposit input, report an error. - deposit, found, err := spvChain.GetDepositRequest( - outpointTransactionHash, - outpointIndex, - ) - if err != nil { - return bitcoin.UnspentTransactionOutput{}, common.Address{}, fmt.Errorf( - "failed to get deposit request: [%v]", - err, - ) - } - - if !found { - return bitcoin.UnspentTransactionOutput{}, common.Address{}, fmt.Errorf( - "deposit: [%v/%v] not found", - outpointTransactionHash, - outpointIndex, - ) - } - if depositAlreadyProcessed { if vault != convertVaultAddress(deposit.Vault) { return bitcoin.UnspentTransactionOutput{}, common.Address{}, fmt.Errorf( diff --git a/pkg/maintainer/spv/deposit_sweep_test.go b/pkg/maintainer/spv/deposit_sweep_test.go index dc61256ccf..69cf92736e 100644 --- a/pkg/maintainer/spv/deposit_sweep_test.go +++ b/pkg/maintainer/spv/deposit_sweep_test.go @@ -134,6 +134,193 @@ func TestSubmitDepositSweepProof(t *testing.T) { ) } +func TestParseDepositSweepTransactionInputs_TaprootDeposit(t *testing.T) { + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + taprootDepositScript, err := bitcoin.PayToTaproot([32]byte{0x01}) + if err != nil { + t.Fatal(err) + } + + walletScript, err := bitcoin.PayToTaproot([32]byte{0x02}) + if err != nil { + t.Fatal(err) + } + + depositTransaction := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 800000, + PublicKeyScript: taprootDepositScript, + }, + }, + } + + if err := btcChain.BroadcastTransaction(depositTransaction); err != nil { + t.Fatal(err) + } + + spvChain.setDepositRequest( + depositTransaction.Hash(), + 0, + &tbtc.DepositChainRequest{ + RevealedAt: time.Unix(1000, 0), + SweptAt: time.Unix(0, 0), + }, + ) + + depositSweepTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: depositTransaction.Hash(), + OutputIndex: 0, + }, + Witness: [][]byte{make([]byte, 64)}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 799000, + PublicKeyScript: walletScript, + }, + }, + } + + mainUTXO, vault, err := parseDepositSweepTransactionInputs( + btcChain, + spvChain, + depositSweepTransaction, + ) + if err != nil { + t.Fatal(err) + } + + expectedMainUtxo := bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{}, + OutputIndex: 0, + }, + Value: 0, + } + if diff := deep.Equal(expectedMainUtxo, mainUTXO); diff != nil { + t.Errorf("invalid main UTXO: %v", diff) + } + + expectedVault := [20]byte{} + testutils.AssertBytesEqual(t, expectedVault[:], vault[:]) +} + +func TestParseDepositSweepTransactionInputs_TaprootMainUtxoAndDeposit( + t *testing.T, +) { + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + walletScript, err := bitcoin.PayToTaproot([32]byte{0x01}) + if err != nil { + t.Fatal(err) + } + + taprootDepositScript, err := bitcoin.PayToTaproot([32]byte{0x02}) + if err != nil { + t.Fatal(err) + } + + mainUtxoTransaction := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 700000, + PublicKeyScript: walletScript, + }, + }, + } + + depositTransaction := &bitcoin.Transaction{ + Version: 2, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 800000, + PublicKeyScript: taprootDepositScript, + }, + }, + } + + for _, transaction := range []*bitcoin.Transaction{ + mainUtxoTransaction, + depositTransaction, + } { + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + } + + spvChain.setDepositRequest( + depositTransaction.Hash(), + 0, + &tbtc.DepositChainRequest{ + RevealedAt: time.Unix(1000, 0), + SweptAt: time.Unix(0, 0), + }, + ) + + depositSweepTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: mainUtxoTransaction.Hash(), + OutputIndex: 0, + }, + Witness: [][]byte{make([]byte, 64)}, + Sequence: 0xffffffff, + }, + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: depositTransaction.Hash(), + OutputIndex: 0, + }, + Witness: [][]byte{make([]byte, 64)}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1499000, + PublicKeyScript: walletScript, + }, + }, + } + + mainUTXO, vault, err := parseDepositSweepTransactionInputs( + btcChain, + spvChain, + depositSweepTransaction, + ) + if err != nil { + t.Fatal(err) + } + + expectedMainUtxo := bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: mainUtxoTransaction.Hash(), + OutputIndex: 0, + }, + Value: 700000, + } + if diff := deep.Equal(expectedMainUtxo, mainUTXO); diff != nil { + t.Errorf("invalid main UTXO: %v", diff) + } + + expectedVault := [20]byte{} + testutils.AssertBytesEqual(t, expectedVault[:], vault[:]) +} + func TestGetUnprovenDepositSweepTransactions(t *testing.T) { bytesFromHex := func(str string) []byte { value, err := hex.DecodeString(str) diff --git a/pkg/maintainer/spv/moved_funds_sweep.go b/pkg/maintainer/spv/moved_funds_sweep.go index 417a1f5347..dfcec0eca1 100644 --- a/pkg/maintainer/spv/moved_funds_sweep.go +++ b/pkg/maintainer/spv/moved_funds_sweep.go @@ -1,7 +1,6 @@ package spv import ( - "bytes" "fmt" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -116,6 +115,16 @@ func parseMovedFundsSweepTransactionInputs( ) } + if input.Outpoint.OutputIndex >= uint32(len(inputTx.Outputs)) { + return bitcoin.UnspentTransactionOutput{}, fmt.Errorf( + "output index [%d] out of range for transaction [%s] "+ + "with [%d] outputs", + input.Outpoint.OutputIndex, + input.Outpoint.TransactionHash.Hex(bitcoin.InternalByteOrder), + len(inputTx.Outputs), + ) + } + // Get the specific output spent by the moved funds sweep transaction. spentOutput := inputTx.Outputs[input.Outpoint.OutputIndex] @@ -210,10 +219,12 @@ func getUnprovenMovedFundsSweepTransactions( // When wallet makes a moved funds sweep transaction, it transfers // funds to itself. Therefore we can search all the transactions that - // pay to the wallet's public key hash. - walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + // pay to one of the wallet's scripts. + walletTransactions, err := getWalletTransactions( walletPublicKeyHash, + wallet, transactionLimit, + btcChain, ) if err != nil { return nil, fmt.Errorf( @@ -327,21 +338,17 @@ func isUnprovenMovedFundsSweepTransaction( // transfer funds to the wallet itself. output := transaction.Outputs[0] - p2pkh, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) - if err != nil { - return false, fmt.Errorf( - "failed to compute p2pkh script for transaction output: [%v]", - err, - ) - } - p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + isWalletOutput, err := isWalletOutputScript( + walletPublicKeyHash, + output.PublicKeyScript, + spvChain, + ) if err != nil { return false, fmt.Errorf( - "failed to compute p2wpkh script for transaction output: [%v]", + "failed to check if output is a wallet output: [%v]", err, ) } - return bytes.Equal(output.PublicKeyScript, p2pkh) || - bytes.Equal(output.PublicKeyScript, p2wpkh), nil + return isWalletOutput, nil } diff --git a/pkg/maintainer/spv/moved_funds_sweep_test.go b/pkg/maintainer/spv/moved_funds_sweep_test.go index a830cedd4c..b3d5c8cb0c 100644 --- a/pkg/maintainer/spv/moved_funds_sweep_test.go +++ b/pkg/maintainer/spv/moved_funds_sweep_test.go @@ -383,3 +383,126 @@ func TestGetUnprovenMovedFundsSweepTransactions(t *testing.T) { t.Errorf("invalid unproven transaction hashes: %v", diff) } } + +func TestGetUnprovenMovedFundsSweepTransactions_FindsTaprootWalletOutput( + t *testing.T, +) { + historyDepth := uint64(5) + transactionLimit := 10 + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + currentBlock := uint64(1000) + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + walletPublicKeyHash := bytes20FromHex( + t, + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ) + walletID := [32]byte{ + 0x23, 0x36, 0xf6, 0x50, 0x04, 0xd8, 0xf1, 0x22, + 0xf1, 0xfe, 0x94, 0x7e, 0xbd, 0x00, 0x9a, 0x8b, + 0x4a, 0xdd, 0x3a, 0x0d, 0x93, 0x73, 0x56, 0xd5, + 0x68, 0xe3, 0x0f, 0x7f, 0xcc, 0x2e, 0x40, 0x08, + } + walletScript, err := bitcoin.PayToTaproot(walletID) + if err != nil { + t.Fatal(err) + } + + var previousTxHash bitcoin.Hash + previousTxHash[0] = 0x04 + movingFundsTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: previousTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: walletScript, + }, + }, + } + if err := btcChain.BroadcastTransaction(movingFundsTx); err != nil { + t.Fatal(err) + } + + movedFundsSweepTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: movingFundsTx.Hash(), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 99000, + PublicKeyScript: walletScript, + }, + }, + } + if err := btcChain.BroadcastTransaction(movedFundsSweepTx); err != nil { + t.Fatal(err) + } + + spvChain.setWallet(walletPublicKeyHash, &tbtc.WalletChainData{ + WalletID: walletID, + State: tbtc.StateLive, + PendingMovedFundsSweepRequestsCount: 1, + }) + spvChain.setMovedFundsSweepRequest( + movingFundsTx.Hash(), + 0, + &tbtc.MovedFundsSweepRequest{ + State: tbtc.MovedFundsStatePending, + }, + ) + + err = spvChain.addPastMovingFundsCommitmentSubmittedEvent( + &tbtc.MovingFundsCommitmentSubmittedEventFilter{ + StartBlock: currentBlock - historyDepth, + }, + &tbtc.MovingFundsCommitmentSubmittedEvent{ + WalletPublicKeyHash: bytes20FromHex( + t, + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ), + TargetWallets: [][20]byte{ + walletPublicKeyHash, + }, + BlockNumber: currentBlock - 1, + }, + ) + if err != nil { + t.Fatal(err) + } + + unprovenTransactions, err := getUnprovenMovedFundsSweepTransactions( + historyDepth, + transactionLimit, + btcChain, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual(t, "transactions count", 1, len(unprovenTransactions)) + expectedHash := movedFundsSweepTx.Hash() + actualHash := unprovenTransactions[0].Hash() + testutils.AssertBytesEqual(t, expectedHash[:], actualHash[:]) +} diff --git a/pkg/maintainer/spv/moving_funds.go b/pkg/maintainer/spv/moving_funds.go index 81d1e13e51..607c6cf7cb 100644 --- a/pkg/maintainer/spv/moving_funds.go +++ b/pkg/maintainer/spv/moving_funds.go @@ -1,7 +1,6 @@ package spv import ( - "bytes" "fmt" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -53,6 +52,7 @@ func submitMovingFundsProof( mainUTXO, walletPublicKeyHash, err := parseMovingFundsTransactionInput( btcChain, + spvChain, transaction, ) if err != nil { @@ -81,6 +81,7 @@ func submitMovingFundsProof( // returns the main UTXO and the wallet public key hash. func parseMovingFundsTransactionInput( btcChain bitcoin.Chain, + spvChain Chain, transaction *bitcoin.Transaction, ) (bitcoin.UnspentTransactionOutput, [20]byte, error) { // Perform a sanity check: a moving funds transaction must have exactly one @@ -103,6 +104,16 @@ func parseMovingFundsTransactionInput( ) } + if input.Outpoint.OutputIndex >= uint32(len(inputTx.Outputs)) { + return bitcoin.UnspentTransactionOutput{}, [20]byte{}, fmt.Errorf( + "output index [%d] out of range for transaction [%s] "+ + "with [%d] outputs", + input.Outpoint.OutputIndex, + input.Outpoint.TransactionHash.Hex(bitcoin.InternalByteOrder), + len(inputTx.Outputs), + ) + } + // Get the specific output spent by the moving funds transaction. spentOutput := inputTx.Outputs[input.Outpoint.OutputIndex] @@ -112,8 +123,11 @@ func parseMovingFundsTransactionInput( Value: spentOutput.Value, } - // Extract the wallet public key hash from script - walletPublicKeyHash, err := bitcoin.ExtractPublicKeyHash(spentOutput.PublicKeyScript) + // Extract the wallet public key hash from script. + walletPublicKeyHash, err := walletPublicKeyHashFromScript( + spvChain, + spentOutput.PublicKeyScript, + ) if err != nil { return bitcoin.UnspentTransactionOutput{}, [20]byte{}, fmt.Errorf( "cannot extract wallet public key hash: [%v]", @@ -197,10 +211,21 @@ func getUnprovenMovingFundsTransactions( // source wallet. targetWalletPublicKeyHash := targetWallets[0] - walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( - targetWalletPublicKeyHash, - transactionLimit, - ) + targetWallet, err := spvChain.GetWallet(targetWalletPublicKeyHash) + var walletTransactions []*bitcoin.Transaction + if err != nil { + walletTransactions, err = btcChain.GetTransactionsForPublicKeyHash( + targetWalletPublicKeyHash, + transactionLimit, + ) + } else { + walletTransactions, err = getWalletTransactions( + targetWalletPublicKeyHash, + targetWallet, + transactionLimit, + btcChain, + ) + } if err != nil { return nil, fmt.Errorf( "failed to get transactions for wallet: [%v]", @@ -289,23 +314,18 @@ func isUnprovenMovingFundsTransaction( // in the same order as target wallets on the list. targetWalletPublicKeyHash := targetWalletsPublicKeyHashes[outputIndex] - p2pkh, err := bitcoin.PayToPublicKeyHash(targetWalletPublicKeyHash) - if err != nil { - return false, fmt.Errorf( - "failed to compute p2pkh script for transaction output: [%v]", - err, - ) - } - p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPublicKeyHash) + isWalletOutput, err := isWalletOutputScript( + targetWalletPublicKeyHash, + output.PublicKeyScript, + spvChain, + ) if err != nil { return false, fmt.Errorf( - "failed to compute p2wpkh script for transaction output: [%v]", + "failed to check if output is a target wallet output: [%v]", err, ) } - - if !bytes.Equal(output.PublicKeyScript, p2pkh) && - !bytes.Equal(output.PublicKeyScript, p2wpkh) { + if !isWalletOutput { return false, nil } } diff --git a/pkg/maintainer/spv/moving_funds_test.go b/pkg/maintainer/spv/moving_funds_test.go index c5f1332b95..3afa89b5a5 100644 --- a/pkg/maintainer/spv/moving_funds_test.go +++ b/pkg/maintainer/spv/moving_funds_test.go @@ -109,6 +109,118 @@ func TestSubmitMovingFundsProof(t *testing.T) { testutils.AssertBytesEqual(t, bytesFromHex("7ac2d9378a1c47e589dfb8095ca95ed2140d2726"), submittedProof.walletPublicKeyHash[:]) } +func TestSubmitMovingFundsProof_TaprootWalletInput(t *testing.T) { + requiredConfirmations := uint(6) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + walletPublicKeyHash := bytes20FromHex( + t, + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ) + walletID := [32]byte{ + 0x23, 0x36, 0xf6, 0x50, 0x04, 0xd8, 0xf1, 0x22, + 0xf1, 0xfe, 0x94, 0x7e, 0xbd, 0x00, 0x9a, 0x8b, + 0x4a, 0xdd, 0x3a, 0x0d, 0x93, 0x73, 0x56, 0xd5, + 0x68, 0xe3, 0x0f, 0x7f, 0xcc, 0x2e, 0x40, 0x08, + } + spvChain.setWallet(walletPublicKeyHash, &tbtc.WalletChainData{ + WalletID: walletID, + }) + + walletOutputScript, err := bitcoin.PayToTaproot(walletID) + if err != nil { + t.Fatal(err) + } + + var previousTxHash bitcoin.Hash + previousTxHash[0] = 0x01 + inputTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: previousTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: walletOutputScript, + }, + }, + } + if err := btcChain.BroadcastTransaction(inputTx); err != nil { + t.Fatal(err) + } + + targetOutputScript, err := bitcoin.PayToWitnessPublicKeyHash( + bytes20FromHex(t, "3091d288521caec06ea912eacfd733edc5a36d6e"), + ) + if err != nil { + t.Fatal(err) + } + + movingFundsTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: inputTx.Hash(), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 99000, + PublicKeyScript: targetOutputScript, + }, + }, + } + + proof := &bitcoin.SpvProof{ + MerkleProof: []byte{0x01}, + TxIndexInBlock: 2, + BitcoinHeaders: []byte{0x03}, + } + mockSpvProofAssembler := func( + hash bitcoin.Hash, + confirmations uint, + btcChain bitcoin.Chain, + ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) { + if hash == movingFundsTx.Hash() && confirmations == requiredConfirmations { + return movingFundsTx, proof, nil + } + + return nil, nil, fmt.Errorf("error while assembling spv proof") + } + + err = submitMovingFundsProof( + movingFundsTx.Hash(), + requiredConfirmations, + btcChain, + spvChain, + mockSpvProofAssembler, + ) + if err != nil { + t.Fatal(err) + } + + submittedProofs := spvChain.getSubmittedMovingFundsProofs() + testutils.AssertIntsEqual(t, "proofs count", 1, len(submittedProofs)) + testutils.AssertBytesEqual( + t, + walletPublicKeyHash[:], + submittedProofs[0].walletPublicKeyHash[:], + ) +} + func TestGetUnprovenMovingFundsTransactions(t *testing.T) { bytesFromHex := func(str string) []byte { value, err := hex.DecodeString(str) @@ -220,6 +332,13 @@ func TestGetUnprovenMovingFundsTransactions(t *testing.T) { }, } + targetWallets := [][20]byte{ + bytes20FromHex("3091d288521caec06ea912eacfd733edc5a36d6e"), + bytes20FromHex("92a6ec889a8fa34f731e639edede4c75e184307c"), + bytes20FromHex("c7302d75072d78be94eb8d36c4b77583c7abb06e"), + bytes20FromHex("2cd680318747b720d67bf4246eb7403b476adb34"), + } + // Record wallet data on both chains. for _, wallet := range wallets { spvChain.setWallet(wallet.walletPublicKeyHash, wallet.data) @@ -231,25 +350,24 @@ func TestGetUnprovenMovingFundsTransactions(t *testing.T) { } } } + for _, targetWallet := range targetWallets { + spvChain.setWallet(targetWallet, &tbtc.WalletChainData{ + WalletID: tbtc.DeriveLegacyWalletID(targetWallet), + }) + } // Add moving funds commitment submitted events for the wallets. // The block number field is just to make them distinguishable while reading. events := []*tbtc.MovingFundsCommitmentSubmittedEvent{ { WalletPublicKeyHash: wallets[0].walletPublicKeyHash, - TargetWallets: [][20]byte{ - bytes20FromHex("3091d288521caec06ea912eacfd733edc5a36d6e"), - bytes20FromHex("92a6ec889a8fa34f731e639edede4c75e184307c"), - bytes20FromHex("c7302d75072d78be94eb8d36c4b77583c7abb06e"), - }, - BlockNumber: 100, + TargetWallets: targetWallets[:3], + BlockNumber: 100, }, { WalletPublicKeyHash: wallets[1].walletPublicKeyHash, - TargetWallets: [][20]byte{ - bytes20FromHex("2cd680318747b720d67bf4246eb7403b476adb34"), - }, - BlockNumber: 200, + TargetWallets: targetWallets[3:], + BlockNumber: 200, }, } @@ -289,3 +407,147 @@ func TestGetUnprovenMovingFundsTransactions(t *testing.T) { t.Errorf("invalid unproven transaction hashes: %v", diff) } } + +func TestGetUnprovenMovingFundsTransactions_FindsTaprootTargetOutput( + t *testing.T, +) { + historyDepth := uint64(5) + transactionLimit := 10 + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + currentBlock := uint64(1000) + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + sourceWalletPublicKeyHash := bytes20FromHex( + t, + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + sourceWalletID := [32]byte{ + 0x23, 0x36, 0xf6, 0x50, 0x04, 0xd8, 0xf1, 0x22, + 0xf1, 0xfe, 0x94, 0x7e, 0xbd, 0x00, 0x9a, 0x8b, + 0x4a, 0xdd, 0x3a, 0x0d, 0x93, 0x73, 0x56, 0xd5, + 0x68, 0xe3, 0x0f, 0x7f, 0xcc, 0x2e, 0x40, 0x08, + } + targetWalletPublicKeyHash := bytes20FromHex( + t, + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ) + targetWalletID := [32]byte{ + 0x90, 0xe7, 0xce, 0x2b, 0x70, 0xe4, 0x23, 0x3a, + 0xbe, 0x6f, 0xd4, 0x9d, 0x45, 0x72, 0x45, 0xe3, + 0x5a, 0x3b, 0xa1, 0x4f, 0x12, 0x1d, 0x16, 0x0c, + 0xeb, 0x61, 0x26, 0xed, 0x1e, 0xdf, 0x14, 0x5a, + } + + sourceScript, err := bitcoin.PayToTaproot(sourceWalletID) + if err != nil { + t.Fatal(err) + } + targetScript, err := bitcoin.PayToTaproot(targetWalletID) + if err != nil { + t.Fatal(err) + } + + var previousTxHash bitcoin.Hash + previousTxHash[0] = 0x02 + inputTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: previousTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: sourceScript, + }, + }, + } + if err := btcChain.BroadcastTransaction(inputTx); err != nil { + t.Fatal(err) + } + + movingFundsTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: inputTx.Hash(), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 99000, + PublicKeyScript: targetScript, + }, + }, + } + if err := btcChain.BroadcastTransaction(movingFundsTx); err != nil { + t.Fatal(err) + } + + spvChain.setWallet(sourceWalletPublicKeyHash, &tbtc.WalletChainData{ + WalletID: sourceWalletID, + MainUtxoHash: spvChain.ComputeMainUtxoHash( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: inputTx.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + ), + State: tbtc.StateMovingFunds, + }) + spvChain.setWallet(targetWalletPublicKeyHash, &tbtc.WalletChainData{ + WalletID: targetWalletID, + State: tbtc.StateLive, + }) + + err = spvChain.addPastMovingFundsCommitmentSubmittedEvent( + &tbtc.MovingFundsCommitmentSubmittedEventFilter{ + StartBlock: currentBlock - historyDepth, + }, + &tbtc.MovingFundsCommitmentSubmittedEvent{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWallets: [][20]byte{ + targetWalletPublicKeyHash, + }, + BlockNumber: currentBlock - 1, + }, + ) + if err != nil { + t.Fatal(err) + } + + unprovenTransactions, err := getUnprovenMovingFundsTransactions( + historyDepth, + transactionLimit, + btcChain, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual(t, "transactions count", 1, len(unprovenTransactions)) + expectedHash := movingFundsTx.Hash() + actualHash := unprovenTransactions[0].Hash() + testutils.AssertBytesEqual( + t, + expectedHash[:], + actualHash[:], + ) +} diff --git a/pkg/maintainer/spv/redemptions.go b/pkg/maintainer/spv/redemptions.go index e504860f81..1c6f2516dd 100644 --- a/pkg/maintainer/spv/redemptions.go +++ b/pkg/maintainer/spv/redemptions.go @@ -1,7 +1,6 @@ package spv import ( - "bytes" "fmt" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -75,6 +74,7 @@ func submitRedemptionProof( } mainUTXO, walletPublicKeyHash, err := parseRedemptionTransactionInput( + spvChain, btcChain, transaction, ) @@ -114,6 +114,7 @@ func submitRedemptionProof( // parseRedemptionTransactionInput parses the transaction's input and // returns the main UTXO and the wallet public key hash. func parseRedemptionTransactionInput( + spvChain Chain, btcChain bitcoin.Chain, transaction *bitcoin.Transaction, ) (bitcoin.UnspentTransactionOutput, [20]byte, error) { @@ -146,18 +147,128 @@ func parseRedemptionTransactionInput( Value: spentOutput.Value, } - // Extract the wallet public key hash from script - walletPublicKeyHash, err := bitcoin.ExtractPublicKeyHash(spentOutput.PublicKeyScript) + walletPublicKeyHash, err := walletPublicKeyHashFromScript( + spvChain, + spentOutput.PublicKeyScript, + ) if err != nil { - return bitcoin.UnspentTransactionOutput{}, [20]byte{}, fmt.Errorf( - "cannot extract wallet public key hash: [%v]", - err, - ) + return bitcoin.UnspentTransactionOutput{}, [20]byte{}, err } return mainUtxo, walletPublicKeyHash, nil } +func walletPublicKeyHashFromScript( + spvChain Chain, + script bitcoin.Script, +) ([20]byte, error) { + switch bitcoin.GetScriptType(script) { + case bitcoin.P2PKHScript, bitcoin.P2WPKHScript: + walletPublicKeyHash, err := bitcoin.ExtractPublicKeyHash(script) + if err != nil { + return [20]byte{}, fmt.Errorf( + "cannot extract wallet public key hash: [%v]", + err, + ) + } + + return walletPublicKeyHash, nil + case bitcoin.P2TRScript: + walletID, err := bitcoin.ExtractTaprootKey(script) + if err != nil { + return [20]byte{}, fmt.Errorf( + "cannot extract wallet Taproot key: [%v]", + err, + ) + } + + walletPublicKeyHash, err := + spvChain.WalletPublicKeyHashForWalletID(walletID) + if err != nil { + return [20]byte{}, fmt.Errorf( + "cannot resolve wallet public key hash for Taproot wallet ID "+ + "[0x%x]: [%v]", + walletID, + err, + ) + } + + return walletPublicKeyHash, nil + default: + return [20]byte{}, fmt.Errorf( + "not a wallet public key hash or Taproot script", + ) + } +} + +type transactionsForPublicKeyScriptsChain interface { + GetTransactionsForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, + limit int, + ) ([]*bitcoin.Transaction, error) +} + +func getWalletTransactions( + walletPublicKeyHash [20]byte, + wallet *tbtc.WalletChainData, + transactionLimit int, + btcChain bitcoin.Chain, +) ([]*bitcoin.Transaction, error) { + scriptChain, ok := btcChain.(transactionsForPublicKeyScriptsChain) + if !ok { + return btcChain.GetTransactionsForPublicKeyHash( + walletPublicKeyHash, + transactionLimit, + ) + } + + publicKeyScripts, err := walletPublicKeyScripts( + walletPublicKeyHash, + wallet, + ) + if err != nil { + return nil, err + } + + return scriptChain.GetTransactionsForPublicKeyScripts( + publicKeyScripts, + transactionLimit, + ) +} + +func walletPublicKeyScripts( + walletPublicKeyHash [20]byte, + wallet *tbtc.WalletChainData, +) ([]bitcoin.Script, error) { + p2pkh, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf("cannot construct P2PKH for wallet: [%v]", err) + } + + p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf("cannot construct P2WPKH for wallet: [%v]", err) + } + + publicKeyScripts := []bitcoin.Script{p2pkh, p2wpkh} + + walletOutputScript, err := tbtc.WalletOutputScript( + walletPublicKeyHash, + wallet.WalletID, + ) + if err != nil { + return nil, fmt.Errorf("cannot construct wallet output script: [%v]", err) + } + + if bitcoin.GetScriptType(walletOutputScript) == bitcoin.P2TRScript { + // FROST Taproot wallets use the canonical wallet ID as the x-only + // Taproot output key. + return []bitcoin.Script{walletOutputScript}, nil + } + + return publicKeyScripts, nil +} + func getUnprovenRedemptionTransactions( historyDepth uint64, transactionLimit int, @@ -221,9 +332,11 @@ func getUnprovenRedemptionTransactions( continue } - walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + walletTransactions, err := getWalletTransactions( walletPublicKeyHash, + wallet, transactionLimit, + btcChain, ) if err != nil { return nil, fmt.Errorf( @@ -305,7 +418,11 @@ func isUnprovenRedemptionTransaction( // First, check if the given output is a change (if it wasn't // found yet). if !changeFound { - isChange, err := isWalletChangeOutput(walletPublicKeyHash, output) + isChange, err := isWalletChangeOutput( + walletPublicKeyHash, + spvChain, + output, + ) if err != nil { return false, fmt.Errorf( "failed to check if output is wallet change: [%v]", @@ -351,17 +468,12 @@ func isUnprovenRedemptionTransaction( func isWalletChangeOutput( walletPublicKeyHash [20]byte, + spvChain Chain, output *bitcoin.TransactionOutput, ) (bool, error) { - walletP2PKH, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) - if err != nil { - return false, fmt.Errorf("cannot construct P2PKH for wallet: [%v]", err) - } - walletP2WPKH, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) - if err != nil { - return false, fmt.Errorf("cannot construct P2WPKH for wallet: [%v]", err) - } - - script := output.PublicKeyScript - return bytes.Equal(script, walletP2PKH) || bytes.Equal(script, walletP2WPKH), nil + return isWalletOutputScript( + walletPublicKeyHash, + output.PublicKeyScript, + spvChain, + ) } diff --git a/pkg/maintainer/spv/redemptions_test.go b/pkg/maintainer/spv/redemptions_test.go index 4f10a3a208..761bccb2ff 100644 --- a/pkg/maintainer/spv/redemptions_test.go +++ b/pkg/maintainer/spv/redemptions_test.go @@ -112,6 +112,140 @@ func TestSubmitRedemptionProof(t *testing.T) { testutils.AssertBytesEqual(t, bytesFromHex("03b74d6893ad46dfdd01b9e0e3b3385f4fce2d1e"), submittedProof.walletPublicKeyHash[:]) } +func TestSubmitRedemptionProof_TaprootMainUtxo(t *testing.T) { + bytesFromHex := func(str string) []byte { + value, err := hex.DecodeString(str) + if err != nil { + t.Fatal(err) + } + + return value + } + + bytes20FromHex := func(str string) [20]byte { + var value [20]byte + copy(value[:], bytesFromHex(str)) + return value + } + + bytes32FromHex := func(str string) [32]byte { + var value [32]byte + copy(value[:], bytesFromHex(str)) + return value + } + + requiredConfirmations := uint(6) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + walletPublicKeyHash := bytes20FromHex( + "2a621226d6f9916a929c0ab8cc7d3252c1485708", + ) + walletID := bytes32FromHex( + "93fd799256287638b1589bc4c8db1b11fcf873796aabeac9edf4cf238f38e596", + ) + walletP2TR, err := bitcoin.PayToTaproot(walletID) + if err != nil { + t.Fatal(err) + } + + spvChain.setWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{ + WalletID: walletID, + State: tbtc.StateLive, + }, + ) + + redemptionInputTransaction := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000000, + PublicKeyScript: walletP2TR, + }, + }, + } + redemptionTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: redemptionInputTransaction.Hash(), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 900000, + PublicKeyScript: bitcoin.Script{0x00, 0x14, 0x01}, + }, + }, + } + + for _, transaction := range []*bitcoin.Transaction{ + redemptionInputTransaction, + redemptionTransaction, + } { + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + } + + proof := &bitcoin.SpvProof{ + MerkleProof: []byte{0x01}, + TxIndexInBlock: 2, + BitcoinHeaders: []byte{0x03}, + } + + mockSpvProofAssembler := func( + hash bitcoin.Hash, + confirmations uint, + btcChain bitcoin.Chain, + ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) { + if hash == redemptionTransaction.Hash() && confirmations == requiredConfirmations { + return redemptionTransaction, proof, nil + } + + return nil, nil, fmt.Errorf("error while assembling spv proof") + } + + err = submitRedemptionProof( + redemptionTransaction.Hash(), + requiredConfirmations, + btcChain, + spvChain, + mockSpvProofAssembler, + getGlobalMetricsRecorder(), + ) + if err != nil { + t.Fatal(err) + } + + submittedProofs := spvChain.getSubmittedRedemptionProofs() + testutils.AssertIntsEqual(t, "proofs count", 1, len(submittedProofs)) + + expectedMainUtxo := bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: redemptionInputTransaction.Hash(), + OutputIndex: 0, + }, + Value: 1000000, + } + if diff := deep.Equal(expectedMainUtxo, submittedProofs[0].mainUTXO); diff != nil { + t.Errorf("invalid main UTXO: %v", diff) + } + + testutils.AssertBytesEqual( + t, + walletPublicKeyHash[:], + submittedProofs[0].walletPublicKeyHash[:], + ) +} + func TestGetUnprovenRedemptionTransactions(t *testing.T) { bytesFromHex := func(str string) []byte { value, err := hex.DecodeString(str) @@ -312,3 +446,311 @@ func TestGetUnprovenRedemptionTransactions(t *testing.T) { t.Errorf("invalid unproven transaction hashes: %v", diff) } } + +func TestGetUnprovenRedemptionTransactions_TaprootWallet(t *testing.T) { + bytesFromHex := func(str string) []byte { + value, err := hex.DecodeString(str) + if err != nil { + t.Fatal(err) + } + + return value + } + + bytes20FromHex := func(str string) [20]byte { + var value [20]byte + copy(value[:], bytesFromHex(str)) + return value + } + + bytes32FromHex := func(str string) [32]byte { + var value [32]byte + copy(value[:], bytesFromHex(str)) + return value + } + + historyDepth := uint64(5) + transactionLimit := 10 + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + currentBlock := uint64(1000) + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + walletPublicKeyHash := bytes20FromHex( + "2a621226d6f9916a929c0ab8cc7d3252c1485708", + ) + walletID := bytes32FromHex( + "93fd799256287638b1589bc4c8db1b11fcf873796aabeac9edf4cf238f38e596", + ) + walletP2TR, err := bitcoin.PayToTaproot(walletID) + if err != nil { + t.Fatal(err) + } + redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash( + bytes20FromHex("e3395778bb7f567e5a527ced184320018e59b4de"), + ) + if err != nil { + t.Fatal(err) + } + + mainUtxoTransaction := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000000, + PublicKeyScript: walletP2TR, + }, + }, + } + redemptionTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: mainUtxoTransaction.Hash(), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 10000, + PublicKeyScript: walletP2TR, + }, + { + Value: 900000, + PublicKeyScript: redeemerScript, + }, + }, + } + + for _, transaction := range []*bitcoin.Transaction{ + mainUtxoTransaction, + redemptionTransaction, + } { + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + } + + mainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: mainUtxoTransaction.Hash(), + OutputIndex: 0, + }, + Value: 1000000, + } + spvChain.setWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{ + WalletID: walletID, + MainUtxoHash: spvChain.ComputeMainUtxoHash(mainUtxo), + State: tbtc.StateLive, + }, + ) + spvChain.setPendingRedemptionRequest( + walletPublicKeyHash, + &tbtc.RedemptionRequest{ + RedeemerOutputScript: redeemerScript, + }, + ) + + err = spvChain.addPastRedemptionRequestedEvent( + &tbtc.RedemptionRequestedEventFilter{ + StartBlock: currentBlock - historyDepth, + }, + &tbtc.RedemptionRequestedEvent{ + WalletPublicKeyHash: walletPublicKeyHash, + BlockNumber: 100, + }, + ) + if err != nil { + t.Fatal(err) + } + + transactions, err := getUnprovenRedemptionTransactions( + historyDepth, + transactionLimit, + btcChain, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual(t, "transactions count", 1, len(transactions)) + if transactions[0].Hash() != redemptionTransaction.Hash() { + t.Errorf( + "invalid transaction hash\nexpected: %v\nactual: %v", + redemptionTransaction.Hash(), + transactions[0].Hash(), + ) + } +} + +func TestGetUnprovenRedemptionTransactions_TaprootWalletIgnoresLegacyAliases( + t *testing.T, +) { + bytesFromHex := func(str string) []byte { + value, err := hex.DecodeString(str) + if err != nil { + t.Fatal(err) + } + + return value + } + + bytes20FromHex := func(str string) [20]byte { + var value [20]byte + copy(value[:], bytesFromHex(str)) + return value + } + + bytes32FromHex := func(str string) [32]byte { + var value [32]byte + copy(value[:], bytesFromHex(str)) + return value + } + + historyDepth := uint64(5) + transactionLimit := 1 + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + currentBlock := uint64(1000) + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + walletPublicKeyHash := bytes20FromHex( + "2a621226d6f9916a929c0ab8cc7d3252c1485708", + ) + walletID := bytes32FromHex( + "93fd799256287638b1589bc4c8db1b11fcf873796aabeac9edf4cf238f38e596", + ) + walletP2TR, err := bitcoin.PayToTaproot(walletID) + if err != nil { + t.Fatal(err) + } + walletP2PKH, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash( + bytes20FromHex("e3395778bb7f567e5a527ced184320018e59b4de"), + ) + if err != nil { + t.Fatal(err) + } + + mainUtxoTransaction := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000000, + PublicKeyScript: walletP2TR, + }, + }, + } + redemptionTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: mainUtxoTransaction.Hash(), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 10000, + PublicKeyScript: walletP2TR, + }, + { + Value: 900000, + PublicKeyScript: redeemerScript, + }, + }, + } + aliasDustTransaction := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1, + PublicKeyScript: walletP2PKH, + }, + }, + } + + for _, transaction := range []*bitcoin.Transaction{ + mainUtxoTransaction, + redemptionTransaction, + aliasDustTransaction, + } { + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + } + + mainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: mainUtxoTransaction.Hash(), + OutputIndex: 0, + }, + Value: 1000000, + } + spvChain.setWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{ + WalletID: walletID, + MainUtxoHash: spvChain.ComputeMainUtxoHash(mainUtxo), + State: tbtc.StateLive, + }, + ) + spvChain.setPendingRedemptionRequest( + walletPublicKeyHash, + &tbtc.RedemptionRequest{ + RedeemerOutputScript: redeemerScript, + }, + ) + + err = spvChain.addPastRedemptionRequestedEvent( + &tbtc.RedemptionRequestedEventFilter{ + StartBlock: currentBlock - historyDepth, + }, + &tbtc.RedemptionRequestedEvent{ + WalletPublicKeyHash: walletPublicKeyHash, + BlockNumber: 100, + }, + ) + if err != nil { + t.Fatal(err) + } + + transactions, err := getUnprovenRedemptionTransactions( + historyDepth, + transactionLimit, + btcChain, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual(t, "transactions count", 1, len(transactions)) + if transactions[0].Hash() != redemptionTransaction.Hash() { + t.Errorf( + "invalid transaction hash\nexpected: %v\nactual: %v", + redemptionTransaction.Hash(), + transactions[0].Hash(), + ) + } +} diff --git a/pkg/maintainer/spv/wallet_output.go b/pkg/maintainer/spv/wallet_output.go new file mode 100644 index 0000000000..046296a275 --- /dev/null +++ b/pkg/maintainer/spv/wallet_output.go @@ -0,0 +1,54 @@ +package spv + +import ( + "bytes" + "fmt" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +func isWalletOutputScript( + walletPublicKeyHash [20]byte, + outputScript bitcoin.Script, + spvChain Chain, +) (bool, error) { + wallet, err := spvChain.GetWallet(walletPublicKeyHash) + if err != nil { + return false, fmt.Errorf("cannot get wallet: [%v]", err) + } + + walletID := wallet.WalletID + if walletID == [32]byte{} { + walletID = tbtc.DeriveLegacyWalletID(walletPublicKeyHash) + } + + walletScript, err := tbtc.WalletOutputScript( + walletPublicKeyHash, + walletID, + ) + if err != nil { + return false, fmt.Errorf("cannot construct wallet output script: [%v]", err) + } + + if bytes.Equal(outputScript, walletScript) { + return true, nil + } + + if bitcoin.GetScriptType(outputScript) != bitcoin.P2PKHScript { + return false, nil + } + + legacyWalletPublicKeyHash, ok := + tbtc.WalletPublicKeyHashFromLegacyWalletID(walletID) + if !ok || !bytes.Equal(legacyWalletPublicKeyHash[:], walletPublicKeyHash[:]) { + return false, nil + } + + walletP2PKH, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) + if err != nil { + return false, fmt.Errorf("cannot construct P2PKH for wallet: [%v]", err) + } + + return bytes.Equal(outputScript, walletP2PKH), nil +} diff --git a/pkg/maintainer/spv/wallet_output_test.go b/pkg/maintainer/spv/wallet_output_test.go new file mode 100644 index 0000000000..d741206b6c --- /dev/null +++ b/pkg/maintainer/spv/wallet_output_test.go @@ -0,0 +1,183 @@ +package spv + +import ( + "encoding/hex" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +func TestIsWalletOutputScript_AcceptsLegacyOutputs(t *testing.T) { + walletPublicKeyHash := bytes20FromHex( + t, + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ) + walletID := tbtc.DeriveLegacyWalletID(walletPublicKeyHash) + + spvChain := newLocalChain() + spvChain.setWallet(walletPublicKeyHash, &tbtc.WalletChainData{ + WalletID: walletID, + }) + + outputScripts := map[string]bitcoin.Script{} + + p2pkh, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + outputScripts["P2PKH"] = p2pkh + + p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + outputScripts["P2WPKH"] = p2wpkh + + for scriptType, outputScript := range outputScripts { + t.Run(scriptType, func(t *testing.T) { + isWalletOutput, err := isWalletOutputScript( + walletPublicKeyHash, + outputScript, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + if !isWalletOutput { + t.Fatalf("expected legacy %s output to be recognized", scriptType) + } + }) + } +} + +func TestIsWalletOutputScript_AcceptsFrostP2TR(t *testing.T) { + walletPublicKeyHash := bytes20FromHex( + t, + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ) + walletID := [32]byte{ + 0x23, 0x36, 0xf6, 0x50, 0x04, 0xd8, 0xf1, 0x22, + 0xf1, 0xfe, 0x94, 0x7e, 0xbd, 0x00, 0x9a, 0x8b, + 0x4a, 0xdd, 0x3a, 0x0d, 0x93, 0x73, 0x56, 0xd5, + 0x68, 0xe3, 0x0f, 0x7f, 0xcc, 0x2e, 0x40, 0x08, + } + + spvChain := newLocalChain() + spvChain.setWallet(walletPublicKeyHash, &tbtc.WalletChainData{ + WalletID: walletID, + }) + + outputScript, err := bitcoin.PayToTaproot(walletID) + if err != nil { + t.Fatal(err) + } + + isWalletOutput, err := isWalletOutputScript( + walletPublicKeyHash, + outputScript, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + if !isWalletOutput { + t.Fatal("expected FROST P2TR output to be recognized") + } +} + +func TestIsWalletOutputScript_RejectsFrostLegacyOutputs(t *testing.T) { + walletPublicKeyHash := bytes20FromHex( + t, + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ) + walletID := [32]byte{ + 0x23, 0x36, 0xf6, 0x50, 0x04, 0xd8, 0xf1, 0x22, + 0xf1, 0xfe, 0x94, 0x7e, 0xbd, 0x00, 0x9a, 0x8b, + 0x4a, 0xdd, 0x3a, 0x0d, 0x93, 0x73, 0x56, 0xd5, + 0x68, 0xe3, 0x0f, 0x7f, 0xcc, 0x2e, 0x40, 0x08, + } + + spvChain := newLocalChain() + spvChain.setWallet(walletPublicKeyHash, &tbtc.WalletChainData{ + WalletID: walletID, + }) + + outputScripts := map[string]bitcoin.Script{} + + p2pkh, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + outputScripts["P2PKH"] = p2pkh + + p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + outputScripts["P2WPKH"] = p2wpkh + + for scriptType, outputScript := range outputScripts { + t.Run(scriptType, func(t *testing.T) { + isWalletOutput, err := isWalletOutputScript( + walletPublicKeyHash, + outputScript, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + if isWalletOutput { + t.Fatalf("expected FROST %s alias output to be rejected", scriptType) + } + }) + } +} + +func TestIsWalletOutputScript_DoesNotAcceptLegacyIDAsP2TR(t *testing.T) { + walletPublicKeyHash := bytes20FromHex( + t, + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ) + walletID := tbtc.DeriveLegacyWalletID(walletPublicKeyHash) + + spvChain := newLocalChain() + spvChain.setWallet(walletPublicKeyHash, &tbtc.WalletChainData{ + WalletID: walletID, + }) + + outputScript, err := bitcoin.PayToTaproot(walletID) + if err != nil { + t.Fatal(err) + } + + isWalletOutput, err := isWalletOutputScript( + walletPublicKeyHash, + outputScript, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + if isWalletOutput { + t.Fatal("expected legacy wallet ID P2TR output to be rejected") + } +} + +func bytes20FromHex(t *testing.T, hexString string) [20]byte { + t.Helper() + + decoded, err := hex.DecodeString(hexString) + if err != nil { + t.Fatal(err) + } + + var result [20]byte + copy(result[:], decoded) + + return result +} diff --git a/pkg/protocol/announcer/announcer.go b/pkg/protocol/announcer/announcer.go index 54860aa0ee..8c203227d8 100644 --- a/pkg/protocol/announcer/announcer.go +++ b/pkg/protocol/announcer/announcer.go @@ -107,6 +107,22 @@ func (a *Announcer) Announce( ) ( []group.MemberIndex, error, +) { + return a.AnnounceMany(ctx, []group.MemberIndex{memberIndex}, sessionID) +} + +// AnnounceMany sends readiness announcements for all given local member +// indexes and listens for announcements from other group members. It returns a +// list of unique members indexes that are ready for the given attempt, +// including the local member indexes. The list is sorted in ascending order. +// This function blocks until the ctx passed as argument is done. +func (a *Announcer) AnnounceMany( + ctx context.Context, + memberIndexes []group.MemberIndex, + sessionID string, +) ( + []group.MemberIndex, + error, ) { messagesChan := make(chan net.Message, announceReceiveBuffer) @@ -114,18 +130,24 @@ func (a *Announcer) Announce( messagesChan <- message }) - err := a.broadcastChannel.Send(ctx, &announcementMessage{ - senderID: memberIndex, - protocolID: a.protocolID, - sessionID: sessionID, - }) - if err != nil { - return nil, fmt.Errorf("cannot send announcement message: [%w]", err) - } - readyMembersIndexesSet := make(map[group.MemberIndex]bool) - // Mark itself as ready. - readyMembersIndexesSet[memberIndex] = true + for _, memberIndex := range memberIndexes { + if readyMembersIndexesSet[memberIndex] { + continue + } + + err := a.broadcastChannel.Send(ctx, &announcementMessage{ + senderID: memberIndex, + protocolID: a.protocolID, + sessionID: sessionID, + }) + if err != nil { + return nil, fmt.Errorf("cannot send announcement message: [%w]", err) + } + + // Mark itself as ready. + readyMembersIndexesSet[memberIndex] = true + } loop: for { @@ -136,7 +158,7 @@ loop: continue } - if announcement.senderID == memberIndex { + if readyMembersIndexesSet[announcement.senderID] { continue } diff --git a/pkg/protocol/announcer/announcer_test.go b/pkg/protocol/announcer/announcer_test.go index b96b69f8da..35eaf56716 100644 --- a/pkg/protocol/announcer/announcer_test.go +++ b/pkg/protocol/announcer/announcer_test.go @@ -228,6 +228,83 @@ func TestAnnouncer(t *testing.T) { } } +func TestAnnouncerAnnounceMany(t *testing.T) { + protocolID := "protocol-test" + groupSize := 4 + honestThreshold := 3 + + operatorPrivateKey, operatorPublicKey, err := operator.GenerateKeyPair( + local_v1.DefaultCurve, + ) + if err != nil { + t.Fatal(err) + } + + localChain := local_v1.ConnectWithKey( + groupSize, + honestThreshold, + operatorPrivateKey, + ) + + operatorAddress, err := localChain.Signing().PublicKeyToAddress( + operatorPublicKey, + ) + if err != nil { + t.Fatal(err) + } + + var operators []chain.Address + for i := 0; i < groupSize; i++ { + operators = append(operators, operatorAddress) + } + + localProvider := local.ConnectWithKey(operatorPublicKey) + broadcastChannel, err := localProvider.BroadcastChannelFor("announce-many") + if err != nil { + t.Fatal(err) + } + + membershipValidator := group.NewMembershipValidator( + &testutils.MockLogger{}, + operators, + localChain.Signing(), + ) + + RegisterUnmarshaller(broadcastChannel) + + announcer := New( + protocolID, + broadcastChannel, + membershipValidator, + ) + + ctx, cancelCtx := context.WithTimeout( + context.Background(), + 4*local.RetransmissionTick, + ) + defer cancelCtx() + + readyMembersIndexes, err := announcer.AnnounceMany( + ctx, + []group.MemberIndex{3, 1, 3}, + "session-test", + ) + if err != nil { + t.Fatal(err) + } + + expected := []group.MemberIndex{1, 3} + if !reflect.DeepEqual(expected, readyMembersIndexes) { + t.Errorf( + "unexpected ready members\n"+ + "expected: [%v]\n"+ + "actual: [%v]", + expected, + readyMembersIndexes, + ) + } +} + func TestUnreadyMembers(t *testing.T) { tests := map[string]struct { readyMembers []group.MemberIndex diff --git a/pkg/tecdsa/retry/retry.go b/pkg/protocol/retry/retry.go similarity index 100% rename from pkg/tecdsa/retry/retry.go rename to pkg/protocol/retry/retry.go diff --git a/pkg/tecdsa/retry/retry_test.go b/pkg/protocol/retry/retry_test.go similarity index 91% rename from pkg/tecdsa/retry/retry_test.go rename to pkg/protocol/retry/retry_test.go index af394920a2..3f6c98d260 100644 --- a/pkg/tecdsa/retry/retry_test.go +++ b/pkg/protocol/retry/retry_test.go @@ -119,6 +119,44 @@ func TestEvaluateRetryParticipantsForKeyGeneration_NotEnoughOperators(t *testing } } +func TestExcludeOperatorTriplets_UsesThirdOperatorSeatCount(t *testing.T) { + groupMembers := []chain.Address{ + "A", "A", "A", + "B", + "C", "C", "C", + } + + operatorToSeatCount := calculateSeatCount(groupMembers) + operators := []chain.Address{"A", "B", "C"} + + // #nosec G404 (insecure random number source (rand)) + // Deterministic RNG is sufficient for deterministic unit tests. + rng := rand.New(rand.NewSource(1)) + + usedOperators, skippedTries, ok := excludeOperatorTriplets( + rng, + groupMembers, + 0, + operatorToSeatCount, + operators, + 2, + ) + + if ok { + t.Fatalf( + "expected no eligible triplet exclusions, got operators: [%v]", + usedOperators, + ) + } + + if skippedTries != 0 { + t.Fatalf( + "expected zero skipped tries when no triplet is eligible, got: [%d]", + skippedTries, + ) + } +} + func TestExcludeOperatorTripletsCountsRightOperatorSeats(t *testing.T) { leftOperator := chain.Address("operator-left") middleOperator := chain.Address("operator-middle") diff --git a/pkg/sortition/sortition.go b/pkg/sortition/sortition.go index a70b91ad21..a5bf056fd2 100644 --- a/pkg/sortition/sortition.go +++ b/pkg/sortition/sortition.go @@ -12,7 +12,12 @@ const ( DefaultStatusCheckTick = 6 * time.Hour ) -var errOperatorUnknown = fmt.Errorf("operator not registered for the staking provider, check Threshold dashboard") +// ErrOperatorUnknown indicates the operator is not registered for a staking +// provider in the targeted sortition pool. Callers running more than one pool +// monitor (e.g. a legacy ECDSA pool and a FROST pool during migration) can +// treat this as a non-fatal, per-pool condition: absence from one pool must not +// abort monitoring of another. +var ErrOperatorUnknown = fmt.Errorf("operator not registered for the staking provider, check Threshold dashboard") // MonitorPool periodically checks the status of the operator in the sortition // pool. If the operator is supposed to be in the sortition pool but is not @@ -32,7 +37,7 @@ func MonitorPool( } if !isRegistered { - return errOperatorUnknown + return ErrOperatorUnknown } err = checkOperatorStatus(logger, chain, policy) diff --git a/pkg/sortition/sortition_test.go b/pkg/sortition/sortition_test.go index 22d1a54f59..6a0822ced6 100644 --- a/pkg/sortition/sortition_test.go +++ b/pkg/sortition/sortition_test.go @@ -48,7 +48,7 @@ func TestMonitorPool_NotRegisteredOperator(t *testing.T) { statusCheckTick, UnconditionalJoinPolicy, ) - testutils.AssertErrorsSame(t, errOperatorUnknown, err) + testutils.AssertErrorsSame(t, ErrOperatorUnknown, err) } func TestMonitorPool_NoStake(t *testing.T) { diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 55206f86fb..bcc1e79d72 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -257,6 +257,10 @@ type BridgeChain interface { // if the wallet was not found. GetWallet(walletPublicKeyHash [20]byte) (*WalletChainData, error) + // WalletPublicKeyHashForWalletID resolves canonical wallet ID to the + // 20-byte compatibility wallet public key hash used by legacy interfaces. + WalletPublicKeyHashForWalletID(walletID [32]byte) ([20]byte, error) + // OnWalletClosed registers a callback that is invoked when an on-chain // notification of the wallet closed is seen. The notification occurs when // the wallet is closed or terminated. @@ -276,6 +280,14 @@ type BridgeChain interface { filter *DepositRevealedEventFilter, ) ([]*DepositRevealedEvent, error) + // PastTaprootDepositRevealedEvents fetches past Taproot deposit reveal + // events according to the provided filter or unfiltered if the filter is + // nil. Returned events are sorted by the block number in ascending order, + // i.e. the latest event is at the end of the slice. + PastTaprootDepositRevealedEvents( + filter *DepositRevealedEventFilter, + ) ([]*TaprootDepositRevealedEvent, error) + // GetPendingRedemptionRequest gets the on-chain pending redemption request // for the given wallet public key hash and redeemer output script. // The returned bool value indicates whether the request was found or not. @@ -329,6 +341,10 @@ type BridgeChain interface { // NewWalletRegisteredEvent represents a new wallet registered event. type NewWalletRegisteredEvent struct { + // WalletID is the canonical bridge wallet identifier. + // For legacy ECDSA wallets, this is derived as a left-padded + // 20-byte wallet public key hash. + WalletID [32]byte EcdsaWalletID [32]byte WalletPublicKeyHash [20]byte BlockNumber uint64 @@ -338,6 +354,7 @@ type NewWalletRegisteredEvent struct { type NewWalletRegisteredEventFilter struct { StartBlock uint64 EndBlock *uint64 + WalletID [][32]byte EcdsaWalletID [][32]byte WalletPublicKeyHash [][20]byte } @@ -388,6 +405,49 @@ func (dre *DepositRevealedEvent) GetWalletPublicKeyHash() [20]byte { return dre.WalletPublicKeyHash } +// TaprootDepositRevealedEvent represents a Taproot deposit reveal event. +// +// The Vault field is nil if the deposit does not target any vault on-chain. +type TaprootDepositRevealedEvent struct { + FundingTxHash bitcoin.Hash + FundingOutputIndex uint32 + Depositor chain.Address + Amount uint64 + BlindingFactor [8]byte + WalletPublicKeyHash [20]byte + WalletXOnlyPublicKey [32]byte + RefundPublicKeyHash [20]byte + RefundXOnlyPublicKey [32]byte + RefundLocktime [4]byte + Vault *chain.Address + BlockNumber uint64 +} + +func (tdre *TaprootDepositRevealedEvent) unpack(extraData *[32]byte) *Deposit { + return &Deposit{ + Utxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: tdre.FundingTxHash, + OutputIndex: tdre.FundingOutputIndex, + }, + Value: int64(tdre.Amount), + }, + Depositor: tdre.Depositor, + BlindingFactor: tdre.BlindingFactor, + WalletPublicKeyHash: tdre.WalletPublicKeyHash, + WalletXOnlyPublicKey: &tdre.WalletXOnlyPublicKey, + RefundPublicKeyHash: tdre.RefundPublicKeyHash, + RefundXOnlyPublicKey: &tdre.RefundXOnlyPublicKey, + RefundLocktime: tdre.RefundLocktime, + Vault: tdre.Vault, + ExtraData: extraData, + } +} + +func (tdre *TaprootDepositRevealedEvent) GetWalletPublicKeyHash() [20]byte { + return tdre.WalletPublicKeyHash +} + // DepositRevealedEventFilter is a component allowing to filter DepositRevealedEvent. type DepositRevealedEventFilter struct { StartBlock uint64 @@ -413,6 +473,10 @@ type DepositChainRequest struct { // WalletChainData represents wallet data stored on-chain. type WalletChainData struct { + // WalletID is the canonical bridge wallet identifier. + // For legacy ECDSA wallets, this is derived as a left-padded + // 20-byte wallet public key hash. + WalletID [32]byte EcdsaWalletID [32]byte MainUtxoHash [32]byte PendingRedemptionsValue uint64 @@ -440,6 +504,19 @@ type WalletProposalValidatorChain interface { }, ) error + // ValidateTaprootDepositSweepProposal validates the given Taproot deposit + // sweep proposal against the chain. It requires some additional data about + // the deposits that must be fetched externally. Returns an error if the + // proposal is not valid or nil otherwise. + ValidateTaprootDepositSweepProposal( + walletPublicKeyHash [20]byte, + proposal *DepositSweepProposal, + depositsExtraInfo []struct { + *Deposit + FundingTx *bitcoin.Transaction + }, + ) error + // ValidateRedemptionProposal validates the given redemption proposal // against the chain. Returns an error if the proposal is not valid or // nil otherwise. diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 15bb4c94ca..1cf9f5b6ba 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -47,6 +47,8 @@ type movingFundsParameters = struct { } type localChain struct { + frostWalletRegistryAvailable bool + dkgResultSubmissionHandlersMutex sync.Mutex dkgResultSubmissionHandlers map[int]func(submission *DKGResultSubmittedEvent) @@ -81,6 +83,9 @@ type localChain struct { pastDepositRevealedEventsMutex sync.Mutex pastDepositRevealedEvents map[[32]byte][]*DepositRevealedEvent + pastTaprootDepositRevealedEventsMutex sync.Mutex + pastTaprootDepositRevealedEvents map[[32]byte][]*TaprootDepositRevealedEvent + pastMovingFundsCommitmentSubmittedEventsMutex sync.Mutex pastMovingFundsCommitmentSubmittedEvents map[[32]byte][]*MovingFundsCommitmentSubmittedEvent @@ -733,6 +738,42 @@ func (lc *localChain) setPastDepositRevealedEvents( return nil } +func (lc *localChain) PastTaprootDepositRevealedEvents( + filter *DepositRevealedEventFilter, +) ([]*TaprootDepositRevealedEvent, error) { + lc.pastTaprootDepositRevealedEventsMutex.Lock() + defer lc.pastTaprootDepositRevealedEventsMutex.Unlock() + + eventsKey, err := buildPastDepositRevealedEventsKey(filter) + if err != nil { + return nil, err + } + + events, ok := lc.pastTaprootDepositRevealedEvents[eventsKey] + if !ok { + return []*TaprootDepositRevealedEvent{}, nil + } + + return events, nil +} + +func (lc *localChain) setPastTaprootDepositRevealedEvents( + filter *DepositRevealedEventFilter, + events []*TaprootDepositRevealedEvent, +) error { + lc.pastTaprootDepositRevealedEventsMutex.Lock() + defer lc.pastTaprootDepositRevealedEventsMutex.Unlock() + + eventsKey, err := buildPastDepositRevealedEventsKey(filter) + if err != nil { + return err + } + + lc.pastTaprootDepositRevealedEvents[eventsKey] = events + + return nil +} + func buildPastDepositRevealedEventsKey( filter *DepositRevealedEventFilter, ) ([32]byte, error) { @@ -892,6 +933,25 @@ func (lc *localChain) GetWallet(walletPublicKeyHash [20]byte) ( return walletChainData, nil } +func (lc *localChain) WalletPublicKeyHashForWalletID( + walletID [32]byte, +) ([20]byte, error) { + lc.walletsMutex.Lock() + defer lc.walletsMutex.Unlock() + + for walletPublicKeyHash, walletData := range lc.wallets { + if walletData == nil { + continue + } + + if walletID == walletData.WalletID || walletID == walletData.EcdsaWalletID { + return walletPublicKeyHash, nil + } + } + + return [20]byte{}, fmt.Errorf("wallet not found") +} + func (lc *localChain) IsWalletRegistered(EcdsaWalletID [32]byte) (bool, error) { lc.walletsMutex.Lock() defer lc.walletsMutex.Unlock() @@ -906,6 +966,27 @@ func (lc *localChain) IsWalletRegistered(EcdsaWalletID [32]byte) (bool, error) { } } + return false, nil +} + +func (lc *localChain) FrostWalletRegistryAvailable() bool { + return lc.frostWalletRegistryAvailable +} + +func (lc *localChain) IsFrostWalletRegistered(walletID [32]byte) (bool, error) { + lc.walletsMutex.Lock() + defer lc.walletsMutex.Unlock() + + for _, walletData := range lc.wallets { + if walletID == walletData.WalletID { + if walletData.State == StateClosed || + walletData.State == StateTerminated { + return false, nil + } + return true, nil + } + } + return false, fmt.Errorf("wallet not found") } @@ -916,6 +997,10 @@ func (lc *localChain) setWallet( lc.walletsMutex.Lock() defer lc.walletsMutex.Unlock() + if walletChainData != nil && walletChainData.WalletID == [32]byte{} { + walletChainData.WalletID = DeriveLegacyWalletID(walletPublicKeyHash) + } + lc.wallets[walletPublicKeyHash] = walletChainData } @@ -1013,6 +1098,21 @@ func (lc *localChain) ValidateDepositSweepProposal( return nil } +func (lc *localChain) ValidateTaprootDepositSweepProposal( + walletPublicKeyHash [20]byte, + proposal *DepositSweepProposal, + depositsExtraInfo []struct { + *Deposit + FundingTx *bitcoin.Transaction + }, +) error { + return lc.ValidateDepositSweepProposal( + walletPublicKeyHash, + proposal, + depositsExtraInfo, + ) +} + func (lc *localChain) setDepositSweepProposalValidationResult( walletPublicKeyHash [20]byte, proposal *DepositSweepProposal, @@ -1467,6 +1567,7 @@ func ConnectWithKey( blocksByTimestamp: make(map[uint64]uint64), blocksHashesByNumber: make(map[uint64][32]byte), pastDepositRevealedEvents: make(map[[32]byte][]*DepositRevealedEvent), + pastTaprootDepositRevealedEvents: make(map[[32]byte][]*TaprootDepositRevealedEvent), pastMovingFundsCommitmentSubmittedEvents: make(map[[32]byte][]*MovingFundsCommitmentSubmittedEvent), depositSweepProposalValidations: make(map[[32]byte]bool), pendingRedemptionRequests: make(map[[32]byte]*RedemptionRequest), diff --git a/pkg/tbtc/coordination.go b/pkg/tbtc/coordination.go index 2dd75e9614..57a78f79f0 100644 --- a/pkg/tbtc/coordination.go +++ b/pkg/tbtc/coordination.go @@ -2,6 +2,7 @@ package tbtc import ( "context" + "crypto/ecdsa" "crypto/sha256" "encoding/binary" "fmt" @@ -209,6 +210,7 @@ func (cf *coordinationFault) String() string { // CoordinationProposalRequest represents a request for a coordination proposal. type CoordinationProposalRequest struct { WalletPublicKeyHash [20]byte + WalletPublicKey *ecdsa.PublicKey WalletOperators []chain.Address ExecutingOperator chain.Address ActionsChecklist []WalletActionType @@ -655,6 +657,7 @@ func (ce *coordinationExecutor) executeLeaderRoutine( proposal, err := ce.generateProposal( &CoordinationProposalRequest{ WalletPublicKeyHash: walletPublicKeyHash, + WalletPublicKey: ce.coordinatedWallet.publicKey, WalletOperators: ce.coordinatedWallet.signingGroupOperators, ExecutingOperator: ce.operatorAddress, ActionsChecklist: actionsChecklist, diff --git a/pkg/tbtc/coordination_test.go b/pkg/tbtc/coordination_test.go index 9aa60670e2..74f3ee3e04 100644 --- a/pkg/tbtc/coordination_test.go +++ b/pkg/tbtc/coordination_test.go @@ -7,6 +7,7 @@ import ( "fmt" "math/big" "reflect" + "sort" "testing" "time" @@ -19,7 +20,6 @@ import ( netlocal "github.com/keep-network/keep-core/pkg/net/local" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" "golang.org/x/exp/slices" "github.com/keep-network/keep-core/internal/testutils" @@ -174,6 +174,9 @@ func TestWatchCoordinationWindows(t *testing.T) { } testutils.AssertIntsEqual(t, "received windows", 2, len(receivedWindows)) + sort.Slice(receivedWindows, func(i, j int) bool { + return receivedWindows[i].coordinationBlock < receivedWindows[j].coordinationBlock + }) testutils.AssertIntsEqual( t, "first window", @@ -1285,12 +1288,8 @@ func TestCoordinationExecutor_ExecuteFollowerRoutine(t *testing.T) { senderID: leaderID, message: big.NewInt(100), attemptNumber: 2, - signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 3, - }, - endBlock: 4500, + signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), + endBlock: 4500, }) if err != nil { t.Error(err) diff --git a/pkg/tbtc/deposit.go b/pkg/tbtc/deposit.go index 361ed38eb5..7339820595 100644 --- a/pkg/tbtc/deposit.go +++ b/pkg/tbtc/deposit.go @@ -22,6 +22,17 @@ const depositScriptFormat = "14%v7508%v7576a914%v8763ac6776a914%v8804%vb175ac68" // https://github.com/keep-network/tbtc-v2/blob/4b6143974b43297e69a45191f0e2b6a25561e72b/solidity/contracts/bridge/Deposit.sol#L246 const depositWithExtraDataScriptFormat = "14%v7520%v7508%v7576a914%v8763ac6776a914%v8804%vb175ac68" +// taprootDepositRefundScriptFormat is the Taproot-native deposit refund +// tapscript format. The placeholders are: depositor, blindingFactor, +// refundLocktime, and refundXOnlyPublicKey. +const taprootDepositRefundScriptFormat = "14%v7508%v7504%vb17520%vac" + +// taprootDepositWithExtraDataRefundScriptFormat is the Taproot-native deposit +// refund tapscript format with optional 32-byte extra data. The placeholders +// are: depositor, extraData, blindingFactor, refundLocktime, and +// refundXOnlyPublicKey. +const taprootDepositWithExtraDataRefundScriptFormat = "14%v7520%v7508%v7504%vb17520%vac" + // Deposit represents a tBTC deposit. type Deposit struct { // Utxo is the unspent output of the deposit funding transaction that @@ -34,8 +45,14 @@ type Deposit struct { BlindingFactor [8]byte // WalletPublicKeyHash is a 20-byte hash of the target wallet public key. WalletPublicKeyHash [20]byte + // WalletXOnlyPublicKey is the 32-byte Taproot internal wallet key. This + // field is set for Taproot-native deposits only. + WalletXOnlyPublicKey *[32]byte // RefundPublicKeyHash is a 20-byte hash of the refund public key. RefundPublicKeyHash [20]byte + // RefundXOnlyPublicKey is the 32-byte Taproot refund key embedded in the + // refund tapscript. This field is set for Taproot-native deposits only. + RefundXOnlyPublicKey *[32]byte // RefundLocktime is a 4-byte value representing the refund locktime. RefundLocktime [4]byte // Vault is an optional field that holds the host chain address of the @@ -46,9 +63,18 @@ type Deposit struct { ExtraData *[32]byte } +// IsTaproot returns true if this deposit was revealed as Taproot-native. +func (d *Deposit) IsTaproot() bool { + return d.WalletXOnlyPublicKey != nil && d.RefundXOnlyPublicKey != nil +} + // Script constructs the deposit P2(W)SH Bitcoin script. This function // assumes the deposit's fields are correctly set. func (d *Deposit) Script() ([]byte, error) { + if d.IsTaproot() { + return nil, fmt.Errorf("Taproot deposit does not have a P2(W)SH script") + } + depositorBytes, err := hex.DecodeString( strings.TrimPrefix(d.Depositor.String(), "0x"), ) @@ -84,3 +110,54 @@ func (d *Deposit) Script() ([]byte, error) { return hex.DecodeString(script) } + +// TaprootRefundScript constructs the deposit refund tapscript. This function +// assumes the deposit's Taproot fields are correctly set. +func (d *Deposit) TaprootRefundScript() ([]byte, error) { + if !d.IsTaproot() { + return nil, fmt.Errorf("deposit is not Taproot-native") + } + + depositorBytes, err := hex.DecodeString( + strings.TrimPrefix(d.Depositor.String(), "0x"), + ) + if err != nil { + return nil, fmt.Errorf("cannot decode depositor field: [%v]", err) + } + if len(depositorBytes) != 20 { + return nil, fmt.Errorf("wrong byte length of depositor field") + } + + var script string + + if d.ExtraData != nil { + script = fmt.Sprintf( + taprootDepositWithExtraDataRefundScriptFormat, + hex.EncodeToString(depositorBytes), + hex.EncodeToString(d.ExtraData[:]), + hex.EncodeToString(d.BlindingFactor[:]), + hex.EncodeToString(d.RefundLocktime[:]), + hex.EncodeToString(d.RefundXOnlyPublicKey[:]), + ) + } else { + script = fmt.Sprintf( + taprootDepositRefundScriptFormat, + hex.EncodeToString(depositorBytes), + hex.EncodeToString(d.BlindingFactor[:]), + hex.EncodeToString(d.RefundLocktime[:]), + hex.EncodeToString(d.RefundXOnlyPublicKey[:]), + ) + } + + return hex.DecodeString(script) +} + +// TaprootMerkleRoot returns the Taproot script tree root for this deposit. +func (d *Deposit) TaprootMerkleRoot() ([32]byte, error) { + refundScript, err := d.TaprootRefundScript() + if err != nil { + return [32]byte{}, err + } + + return bitcoin.TaprootLeafHash(refundScript) +} diff --git a/pkg/tbtc/deposit_sweep.go b/pkg/tbtc/deposit_sweep.go index 824ce29d28..3fd95aa032 100644 --- a/pkg/tbtc/deposit_sweep.go +++ b/pkg/tbtc/deposit_sweep.go @@ -159,8 +159,8 @@ func (dsa *depositSweepAction) execute() error { return fmt.Errorf("validate proposal step failed: [%v]", err) } - walletMainUtxo, err := DetermineWalletMainUtxo( - walletPublicKeyHash, + walletMainUtxo, err := DetermineWalletMainUtxoForPublicKey( + dsa.wallet().publicKey, dsa.chain, dsa.btcChain, ) @@ -175,8 +175,8 @@ func (dsa *depositSweepAction) execute() error { ) } - err = EnsureWalletSyncedBetweenChains( - walletPublicKeyHash, + err = EnsureWalletSyncedBetweenChainsForPublicKey( + dsa.wallet().publicKey, walletMainUtxo, dsa.chain, dsa.btcChain, @@ -288,6 +288,14 @@ func ValidateDepositSweepProposal( filter *DepositRevealedEventFilter, ) ([]*DepositRevealedEvent, error) + // PastTaprootDepositRevealedEvents fetches past Taproot deposit reveal + // events according to the provided filter or unfiltered if the filter + // is nil. Returned events are sorted by the block number in the + // ascending order, i.e. the latest event is at the end of the slice. + PastTaprootDepositRevealedEvents( + filter *DepositRevealedEventFilter, + ) ([]*TaprootDepositRevealedEvent, error) + // ValidateDepositSweepProposal validates the given deposit sweep proposal // against the chain. It requires some additional data about the deposits // that must be fetched externally. Returns an error if the proposal is @@ -301,6 +309,19 @@ func ValidateDepositSweepProposal( }, ) error + // ValidateTaprootDepositSweepProposal validates the given Taproot + // deposit sweep proposal against the chain. It requires some additional + // data about the deposits that must be fetched externally. Returns an + // error if the proposal is not valid or nil otherwise. + ValidateTaprootDepositSweepProposal( + walletPublicKeyHash [20]byte, + proposal *DepositSweepProposal, + depositsExtraInfo []struct { + *Deposit + FundingTx *bitcoin.Transaction + }, + ) error + // GetDepositRequest gets the on-chain deposit request for the given // funding transaction hash and output index.The returned values represent: // - deposit request which is non-nil only when the deposit request was @@ -331,6 +352,8 @@ func ValidateDepositSweepProposal( return nil, fmt.Errorf("proposal's reveal blocks list has a wrong length") } + taprootDepositsCount := 0 + for i, depositKey := range proposal.DepositsKeys { depositDisplayIndex := fmt.Sprintf("%v/%v", i+1, len(proposal.DepositsKeys)) @@ -377,6 +400,12 @@ func ValidateDepositSweepProposal( revealBlock := proposal.DepositsRevealBlocks[i].Uint64() + filter := &DepositRevealedEventFilter{ + StartBlock: revealBlock, + EndBlock: &revealBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + } + // We need to fetch the past DepositRevealed event for the given deposit. // It may be tempting to fetch such events for all deposit keys // in the proposal using a single call, however, this solution has @@ -387,11 +416,7 @@ func ValidateDepositSweepProposal( // We have the revealBlock passed by the coordinator within the proposal // so, we can use it to make a narrow call. Moreover, we use the // wallet PKH as additional filter to limit the size of returned data. - events, err := chain.PastDepositRevealedEvents(&DepositRevealedEventFilter{ - StartBlock: revealBlock, - EndBlock: &revealBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }) + events, err := chain.PastDepositRevealedEvents(filter) if err != nil { return nil, fmt.Errorf( "cannot get on-chain DepositRevealed events for deposit [%v]: [%v]", @@ -411,9 +436,27 @@ func ValidateDepositSweepProposal( } } - if matchingEvent == nil { + taprootEvents, err := chain.PastTaprootDepositRevealedEvents(filter) + if err != nil { return nil, fmt.Errorf( - "no matching DepositRevealed event for deposit [%v]: [%v]", + "cannot get on-chain TaprootDepositRevealed events for deposit [%v]: [%v]", + depositDisplayIndex, + err, + ) + } + + var matchingTaprootEvent *TaprootDepositRevealedEvent + for _, event := range taprootEvents { + if event.FundingTxHash == depositKey.FundingTxHash && + event.FundingOutputIndex == depositKey.FundingOutputIndex { + matchingTaprootEvent = event + break + } + } + + if matchingEvent == nil && matchingTaprootEvent == nil { + return nil, fmt.Errorf( + "no matching DepositRevealed or TaprootDepositRevealed event for deposit [%v]: [%v]", depositDisplayIndex, err, ) @@ -441,18 +484,40 @@ func ValidateDepositSweepProposal( *Deposit FundingTx *bitcoin.Transaction }{ - Deposit: matchingEvent.unpack(depositRequest.ExtraData), + Deposit: func() *Deposit { + if matchingTaprootEvent != nil { + taprootDepositsCount++ + return matchingTaprootEvent.unpack(depositRequest.ExtraData) + } + + return matchingEvent.unpack(depositRequest.ExtraData) + }(), FundingTx: fundingTx, } } + if taprootDepositsCount > 0 && taprootDepositsCount != len(proposal.DepositsKeys) { + return nil, fmt.Errorf( + "mixed legacy and Taproot deposits are not supported in one sweep proposal", + ) + } + validateProposalLogger.Infof("calling chain for proposal validation") - err := chain.ValidateDepositSweepProposal( - walletPublicKeyHash, - proposal, - depositExtraInfo, - ) + var err error + if taprootDepositsCount > 0 { + err = chain.ValidateTaprootDepositSweepProposal( + walletPublicKeyHash, + proposal, + depositExtraInfo, + ) + } else { + err = chain.ValidateDepositSweepProposal( + walletPublicKeyHash, + proposal, + depositExtraInfo, + ) + } if err != nil { return nil, fmt.Errorf("deposit sweep proposal is invalid: [%v]", err) } @@ -508,6 +573,41 @@ func assembleDepositSweepTransaction( return nil, fmt.Errorf("at least one deposit is required") } + taprootDepositsCount := 0 + for _, deposit := range deposits { + if deposit.IsTaproot() { + taprootDepositsCount++ + } + } + + if taprootDepositsCount > 0 && taprootDepositsCount != len(deposits) { + return nil, fmt.Errorf( + "mixed legacy and Taproot deposits are not supported in one sweep transaction", + ) + } + + taprootSweep := taprootDepositsCount > 0 + + if !taprootSweep && walletMainUtxo != nil { + scriptType, err := walletMainUtxoScriptType( + bitcoinChain, + walletMainUtxo, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot inspect wallet main UTXO script: [%v]", + err, + ) + } + + if scriptType == bitcoin.P2TRScript { + return nil, fmt.Errorf( + "legacy deposit sweeps are not supported for " + + "Taproot wallet main UTXOs", + ) + } + } + builder := bitcoin.NewTransactionBuilder(bitcoinChain) if walletMainUtxo != nil { @@ -521,29 +621,73 @@ func assembleDepositSweepTransaction( } for i, deposit := range deposits { - depositScript, err := deposit.Script() - if err != nil { - return nil, fmt.Errorf( - "cannot get script for deposit [%v]: [%v]", - i, - err, + if deposit.IsTaproot() { + merkleRoot, err := deposit.TaprootMerkleRoot() + if err != nil { + return nil, fmt.Errorf( + "cannot compute Taproot merkle root for deposit [%v]: [%v]", + i, + err, + ) + } + + err = builder.AddTaprootKeyPathInputWithMerkleRoot( + deposit.Utxo, + *deposit.WalletXOnlyPublicKey, + merkleRoot, ) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to Taproot deposit [%v] UTXO: [%v]", + i, + err, + ) + } + } else { + depositScript, err := deposit.Script() + if err != nil { + return nil, fmt.Errorf( + "cannot get script for deposit [%v]: [%v]", + i, + err, + ) + } + + err = builder.AddScriptHashInput(deposit.Utxo, depositScript) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to deposit [%v] UTXO: [%v]", + i, + err, + ) + } } + } - err = builder.AddScriptHashInput(deposit.Utxo, depositScript) + if taprootSweep && !builder.HasOnlyTaprootKeyPathInputs() { + return nil, fmt.Errorf( + "Taproot deposit sweep requires a Taproot wallet main UTXO", + ) + } + + var outputScript bitcoin.Script + var err error + if taprootSweep { + walletXOnlyPublicKey, err := walletXOnlyPublicKey(walletPublicKey) if err != nil { - return nil, fmt.Errorf( - "cannot add input pointing to deposit [%v] UTXO: [%v]", - i, - err, - ) + return nil, err } - } - walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) - outputScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) - if err != nil { - return nil, fmt.Errorf("cannot compute output script: [%v]", err) + outputScript, err = bitcoin.PayToTaproot(walletXOnlyPublicKey) + if err != nil { + return nil, fmt.Errorf("cannot compute Taproot output script: [%v]", err) + } + } else { + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + outputScript, err = bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf("cannot compute output script: [%v]", err) + } } outputValue := builder.TotalInputsValue() - fee diff --git a/pkg/tbtc/deposit_sweep_test.go b/pkg/tbtc/deposit_sweep_test.go index c98f75a3c0..ac03321cbf 100644 --- a/pkg/tbtc/deposit_sweep_test.go +++ b/pkg/tbtc/deposit_sweep_test.go @@ -2,15 +2,19 @@ package tbtc import ( "context" + "crypto/ecdsa" + "encoding/hex" "fmt" "math/big" + "strings" "testing" "time" - "github.com/keep-network/keep-core/pkg/tecdsa" - + "github.com/btcsuite/btcd/btcec" "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/tbtc/internal/test" ) @@ -78,7 +82,16 @@ func TestDepositSweepAction_Execute(t *testing.T) { *Deposit FundingTx *bitcoin.Transaction }{ - Deposit: (*Deposit)(deposit), + Deposit: &Deposit{ + Utxo: deposit.Utxo, + Depositor: deposit.Depositor, + BlindingFactor: deposit.BlindingFactor, + WalletPublicKeyHash: deposit.WalletPublicKeyHash, + RefundPublicKeyHash: deposit.RefundPublicKeyHash, + RefundLocktime: deposit.RefundLocktime, + Vault: deposit.Vault, + ExtraData: deposit.ExtraData, + }, FundingTx: fundingTx, } @@ -171,16 +184,15 @@ func TestDepositSweepAction_Execute(t *testing.T) { // Create a signing executor mock instance. signingExecutor := newMockWalletSigningExecutor() - // The signatures within the scenario fixture are in the format - // suitable for applying them directly to a Bitcoin transaction. - // However, the signing executor operates on raw tECDSA signatures - // so, we need to unpack them first. - rawSignatures := make([]*tecdsa.Signature, len(scenario.Signatures)) + // The signatures within the scenario fixture are represented as + // big integer components and need conversion to runtime signature + // containers used by signing executor. + rawSignatures := make([]*frost.Signature, len(scenario.Signatures)) for i, signature := range scenario.Signatures { - rawSignatures[i] = &tecdsa.Signature{ - R: signature.R, - S: signature.S, - } + rawSignatures[i] = mustFrostSignatureFromBigInts( + signature.R, + signature.S, + ) } // Set up the signing executor mock to return the signatures from @@ -318,3 +330,415 @@ func TestAssembleDepositSweepTransaction(t *testing.T) { }) } } + +func TestAssembleDepositSweepTransaction_TaprootDeposit(t *testing.T) { + hexToSlice := func(hexString string) []byte { + bytes, err := hex.DecodeString(hexString) + if err != nil { + t.Fatalf("error while converting [%v]: [%v]", hexString, err) + } + return bytes + } + + var walletXOnlyPublicKey [32]byte + copy( + walletXOnlyPublicKey[:], + hexToSlice("2336f65004d8f122f1fe947ebd009a8b4add3a0d937356d568e30f7fcc2e4008"), + ) + + compressedWalletPublicKey := append([]byte{0x02}, walletXOnlyPublicKey[:]...) + parsedWalletPublicKey, err := btcec.ParsePubKey( + compressedWalletPublicKey, + btcec.S256(), + ) + if err != nil { + t.Fatal(err) + } + walletPublicKey := &ecdsa.PublicKey{ + Curve: btcec.S256(), + X: parsedWalletPublicKey.X, + Y: parsedWalletPublicKey.Y, + } + + var refundXOnlyPublicKey [32]byte + copy( + refundXOnlyPublicKey[:], + hexToSlice("11223344556677889900aabbccddeeff00112233445566778899aabbccddeeff"), + ) + + depositOne := &Deposit{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + WalletXOnlyPublicKey: &walletXOnlyPublicKey, + RefundXOnlyPublicKey: &refundXOnlyPublicKey, + } + copy(depositOne.BlindingFactor[:], hexToSlice("f9f0c90d00039523")) + copy(depositOne.WalletPublicKeyHash[:], hexToSlice("c92a772f11bc97d8938a16a9db435401f4e6a7bc")) + copy(depositOne.RefundPublicKeyHash[:], hexToSlice("c2a27a88d8d03e271e8edc556923e9398619f17c")) + copy(depositOne.RefundLocktime[:], hexToSlice("60bcea61")) + + merkleRootOne, err := depositOne.TaprootMerkleRoot() + if err != nil { + t.Fatal(err) + } + + fundingOutputScriptOne, err := bitcoin.PayToTaprootWithScriptTree( + walletXOnlyPublicKey, + merkleRootOne, + ) + if err != nil { + t.Fatal(err) + } + + depositTwo := &Deposit{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + WalletXOnlyPublicKey: &walletXOnlyPublicKey, + RefundXOnlyPublicKey: &refundXOnlyPublicKey, + } + copy(depositTwo.BlindingFactor[:], hexToSlice("f9f0c90d00039523")) + copy(depositTwo.WalletPublicKeyHash[:], hexToSlice("c92a772f11bc97d8938a16a9db435401f4e6a7bc")) + copy(depositTwo.RefundPublicKeyHash[:], hexToSlice("c2a27a88d8d03e271e8edc556923e9398619f17c")) + copy(depositTwo.RefundLocktime[:], hexToSlice("60bcea61")) + var extraData [32]byte + copy( + extraData[:], + hexToSlice( + "a9b38ea6435c8941d6eda6a46b68e3e2117196995bd154ab55196396b03d9bda", + ), + ) + depositTwo.ExtraData = &extraData + + merkleRootTwo, err := depositTwo.TaprootMerkleRoot() + if err != nil { + t.Fatal(err) + } + + fundingOutputScriptTwo, err := bitcoin.PayToTaprootWithScriptTree( + walletXOnlyPublicKey, + merkleRootTwo, + ) + if err != nil { + t.Fatal(err) + } + + var previousTxHash bitcoin.Hash + copy(previousTxHash[:], hexToSlice("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20")) + fundingTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: previousTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: fundingOutputScriptOne, + }, + { + Value: 110000, + PublicKeyScript: fundingOutputScriptTwo, + }, + }, + } + + bitcoinChain := newLocalBitcoinChain() + if err := bitcoinChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + + depositOne.Utxo = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTx.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + depositTwo.Utxo = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTx.Hash(), + OutputIndex: 1, + }, + Value: 110000, + } + + builder, err := assembleDepositSweepTransaction( + bitcoinChain, + walletPublicKey, + nil, + []*Deposit{depositOne, depositTwo}, + 1000, + ) + if err != nil { + t.Fatal(err) + } + + if !builder.HasOnlyTaprootKeyPathInputs() { + t.Fatal("expected only Taproot key-path inputs") + } + + merkleRoots := builder.TaprootKeyPathInputMerkleRoots() + if len(merkleRoots) != 2 || merkleRoots[0] == nil || merkleRoots[1] == nil { + t.Fatalf("expected two Taproot merkle roots") + } + testutils.AssertBytesEqual(t, merkleRootOne[:], merkleRoots[0][:]) + testutils.AssertBytesEqual(t, merkleRootTwo[:], merkleRoots[1][:]) + + unsignedTx := builder.UnsignedTransaction() + if len(unsignedTx.Outputs) != 1 { + t.Fatalf("unexpected outputs count: [%v]", len(unsignedTx.Outputs)) + } + + expectedWalletOutputScript, err := bitcoin.PayToTaproot(walletXOnlyPublicKey) + if err != nil { + t.Fatal(err) + } + testutils.AssertBytesEqual( + t, + expectedWalletOutputScript, + unsignedTx.Outputs[0].PublicKeyScript, + ) + testutils.AssertIntsEqual( + t, + "output value", + 209000, + int(unsignedTx.Outputs[0].Value), + ) +} + +func TestAssembleDepositSweepTransaction_RejectsLegacyDepositsWithTaprootWalletMainUtxo( + t *testing.T, +) { + bitcoinChain := newLocalBitcoinChain() + walletPublicKey := testWalletPublicKeyFromXOnly( + t, + "2336f65004d8f122f1fe947ebd009a8b4add3a0d937356d568e30f7fcc2e4008", + ) + walletMainUtxo := testTaprootWalletMainUtxo( + t, + bitcoinChain, + walletPublicKey, + ) + + _, err := assembleDepositSweepTransaction( + bitcoinChain, + walletPublicKey, + walletMainUtxo, + []*Deposit{ + { + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + }, + }, + 1000, + ) + if err == nil { + t.Fatal("expected legacy deposit sweep with Taproot main UTXO rejection") + } + if !strings.Contains(err.Error(), "legacy deposit sweeps") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestValidateDepositSweepProposal_PrefersTaprootRevealOverCompatibilityReveal(t *testing.T) { + hexToSlice := func(hexString string) []byte { + bytes, err := hex.DecodeString(hexString) + if err != nil { + t.Fatalf("error while converting [%v]: [%v]", hexString, err) + } + return bytes + } + + var fundingTxHash bitcoin.Hash + copy(fundingTxHash[:], hexToSlice("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20")) + + fundingTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: bitcoin.Script{ + 0x51, 0x20, + 0x23, 0x36, 0xf6, 0x50, 0x04, 0xd8, 0xf1, 0x22, + 0xf1, 0xfe, 0x94, 0x7e, 0xbd, 0x00, 0x9a, 0x8b, + 0x4a, 0xdd, 0x3a, 0x0d, 0x93, 0x73, 0x56, 0xd5, + 0x68, 0xe3, 0x0f, 0x7f, 0xcc, 0x2e, 0x40, 0x08, + }, + }, + }, + } + + bitcoinChain := newLocalBitcoinChain() + if err := bitcoinChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + + fundingOutputIndex := uint32(0) + revealBlock := uint64(123) + var blindingFactor [8]byte + copy(blindingFactor[:], hexToSlice("f9f0c90d00039523")) + var walletPublicKeyHash [20]byte + copy(walletPublicKeyHash[:], hexToSlice("c92a772f11bc97d8938a16a9db435401f4e6a7bc")) + var walletXOnlyPublicKey [32]byte + copy( + walletXOnlyPublicKey[:], + hexToSlice("2336f65004d8f122f1fe947ebd009a8b4add3a0d937356d568e30f7fcc2e4008"), + ) + var refundPublicKeyHash [20]byte + copy(refundPublicKeyHash[:], hexToSlice("c2a27a88d8d03e271e8edc556923e9398619f17c")) + var refundXOnlyPublicKey [32]byte + copy( + refundXOnlyPublicKey[:], + hexToSlice("11223344556677889900aabbccddeeff00112233445566778899aabbccddeeff"), + ) + var refundLocktime [4]byte + copy(refundLocktime[:], hexToSlice("60bcea61")) + depositor := chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637") + + proposal := &DepositSweepProposal{ + DepositsKeys: []struct { + FundingTxHash bitcoin.Hash + FundingOutputIndex uint32 + }{ + { + FundingTxHash: fundingTx.Hash(), + FundingOutputIndex: fundingOutputIndex, + }, + }, + SweepTxFee: big.NewInt(1000), + DepositsRevealBlocks: []*big.Int{ + big.NewInt(int64(revealBlock)), + }, + } + + validationChain := &depositSweepValidationChainStub{ + legacyEvents: []*DepositRevealedEvent{ + { + FundingTxHash: fundingTx.Hash(), + FundingOutputIndex: fundingOutputIndex, + Depositor: depositor, + Amount: 100000, + BlindingFactor: blindingFactor, + WalletPublicKeyHash: walletPublicKeyHash, + RefundPublicKeyHash: refundPublicKeyHash, + RefundLocktime: refundLocktime, + BlockNumber: revealBlock, + }, + }, + taprootEvents: []*TaprootDepositRevealedEvent{ + { + FundingTxHash: fundingTx.Hash(), + FundingOutputIndex: fundingOutputIndex, + Depositor: depositor, + Amount: 100000, + BlindingFactor: blindingFactor, + WalletPublicKeyHash: walletPublicKeyHash, + WalletXOnlyPublicKey: walletXOnlyPublicKey, + RefundPublicKeyHash: refundPublicKeyHash, + RefundXOnlyPublicKey: refundXOnlyPublicKey, + RefundLocktime: refundLocktime, + BlockNumber: revealBlock, + }, + }, + depositRequest: &DepositChainRequest{ + Depositor: depositor, + Amount: 100000, + }, + } + + deposits, err := ValidateDepositSweepProposal( + logger.With(), + walletPublicKeyHash, + proposal, + 1, + validationChain, + bitcoinChain, + ) + if err != nil { + t.Fatal(err) + } + + if validationChain.legacyValidationCalled { + t.Fatal("legacy validation should not be called when a Taproot event matches") + } + if !validationChain.taprootValidationCalled { + t.Fatal("Taproot validation was not called") + } + if len(deposits) != 1 { + t.Fatalf("unexpected deposits count: [%v]", len(deposits)) + } + if !deposits[0].IsTaproot() { + t.Fatal("expected validated deposit to be Taproot-native") + } +} + +type depositSweepValidationChainStub struct { + legacyEvents []*DepositRevealedEvent + taprootEvents []*TaprootDepositRevealedEvent + depositRequest *DepositChainRequest + + legacyValidationCalled bool + taprootValidationCalled bool +} + +func (dsvcs *depositSweepValidationChainStub) PastDepositRevealedEvents( + filter *DepositRevealedEventFilter, +) ([]*DepositRevealedEvent, error) { + return dsvcs.legacyEvents, nil +} + +func (dsvcs *depositSweepValidationChainStub) PastTaprootDepositRevealedEvents( + filter *DepositRevealedEventFilter, +) ([]*TaprootDepositRevealedEvent, error) { + return dsvcs.taprootEvents, nil +} + +func (dsvcs *depositSweepValidationChainStub) ValidateDepositSweepProposal( + walletPublicKeyHash [20]byte, + proposal *DepositSweepProposal, + depositsExtraInfo []struct { + *Deposit + FundingTx *bitcoin.Transaction + }, +) error { + dsvcs.legacyValidationCalled = true + return fmt.Errorf("legacy validation should not be called") +} + +func (dsvcs *depositSweepValidationChainStub) ValidateTaprootDepositSweepProposal( + walletPublicKeyHash [20]byte, + proposal *DepositSweepProposal, + depositsExtraInfo []struct { + *Deposit + FundingTx *bitcoin.Transaction + }, +) error { + dsvcs.taprootValidationCalled = true + + if len(depositsExtraInfo) != 1 { + return fmt.Errorf("unexpected deposits extra info count: [%v]", len(depositsExtraInfo)) + } + if !depositsExtraInfo[0].Deposit.IsTaproot() { + return fmt.Errorf("expected Taproot deposit extra info") + } + + return nil +} + +func (dsvcs *depositSweepValidationChainStub) GetDepositRequest( + fundingTxHash bitcoin.Hash, + fundingOutputIndex uint32, +) (*DepositChainRequest, bool, error) { + return dsvcs.depositRequest, true, nil +} diff --git a/pkg/tbtc/deposit_test.go b/pkg/tbtc/deposit_test.go index 72b86344cd..ef978837d7 100644 --- a/pkg/tbtc/deposit_test.go +++ b/pkg/tbtc/deposit_test.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "testing" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/internal/testutils" @@ -82,3 +83,113 @@ func TestDeposit_Script(t *testing.T) { }) } } + +func TestDeposit_TaprootRefundScript(t *testing.T) { + hexToSlice := func(hexString string) []byte { + bytes, err := hex.DecodeString(hexString) + if err != nil { + t.Fatalf("error while converting [%v]: [%v]", hexString, err) + } + return bytes + } + + var tests = map[string]struct { + extraData string + expectedScript string + expectedMerkleRoot string + expectedTaprootKey string + expectedOutputScript string + }{ + "no extra data": { + extraData: "", + expectedScript: "14934b98637ca318a4d6e7ca6ffd1690b8e77df6377508" + + "f9f0c90d00039523750460bcea61b1752011223344556677889900aabb" + + "ccddeeff00112233445566778899aabbccddeeffac", + expectedMerkleRoot: "3d6f9a2fea1de0a6c260d1fbc0343c9b2ed84307e6a7" + + "231139b78438448ee8c0", + expectedTaprootKey: "90e7ce2b6cd476b7a1c2c7f6585c3fd0eae4379a508e" + + "981ed422b3e28b9ae8c2", + expectedOutputScript: "512090e7ce2b6cd476b7a1c2c7f6585c3fd0eae4379" + + "a508e981ed422b3e28b9ae8c2", + }, + "with extra data": { + extraData: "a9b38ea6435c8941d6eda6a46b68e3e2117196995bd154ab55" + + "196396b03d9bda", + expectedScript: "14934b98637ca318a4d6e7ca6ffd1690b8e77df6377520" + + "a9b38ea6435c8941d6eda6a46b68e3e2117196995bd154ab55196396" + + "b03d9bda7508f9f0c90d00039523750460bcea61b175201122334455" + + "6677889900aabbccddeeff00112233445566778899aabbccddeeffac", + expectedMerkleRoot: "6968648895261db4f667ff977b3bbd9b4684fe756050" + + "894b092fd0e24e24f90f", + expectedTaprootKey: "b57ad22351a7a074b6588836d08fbecae35b61ef9eeb" + + "35376a1c5f3d6049376e", + expectedOutputScript: "5120b57ad22351a7a074b6588836d08fbecae35b61ef" + + "9eeb35376a1c5f3d6049376e", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + d := new(Deposit) + d.Depositor = chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637") + copy(d.BlindingFactor[:], hexToSlice("f9f0c90d00039523")) + copy(d.WalletPublicKeyHash[:], hexToSlice("c92a772f11bc97d8938a16a9db435401f4e6a7bc")) + copy(d.RefundPublicKeyHash[:], hexToSlice("c2a27a88d8d03e271e8edc556923e9398619f17c")) + copy(d.RefundLocktime[:], hexToSlice("60bcea61")) + + var walletXOnlyPublicKey [32]byte + copy( + walletXOnlyPublicKey[:], + hexToSlice("2336f65004d8f122f1fe947ebd009a8b4add3a0d937356d568e30f7fcc2e4008"), + ) + d.WalletXOnlyPublicKey = &walletXOnlyPublicKey + + var refundXOnlyPublicKey [32]byte + copy( + refundXOnlyPublicKey[:], + hexToSlice("11223344556677889900aabbccddeeff00112233445566778899aabbccddeeff"), + ) + d.RefundXOnlyPublicKey = &refundXOnlyPublicKey + + if len(test.extraData) > 0 { + var extraData [32]byte + copy(extraData[:], hexToSlice(test.extraData)) + d.ExtraData = &extraData + } + + refundScript, err := d.TaprootRefundScript() + if err != nil { + t.Fatal(err) + } + testutils.AssertBytesEqual(t, hexToSlice(test.expectedScript), refundScript) + + merkleRoot, err := d.TaprootMerkleRoot() + if err != nil { + t.Fatal(err) + } + testutils.AssertBytesEqual(t, hexToSlice(test.expectedMerkleRoot), merkleRoot[:]) + + outputScript, err := bitcoin.PayToTaprootWithScriptTree( + *d.WalletXOnlyPublicKey, + merkleRoot, + ) + if err != nil { + t.Fatal(err) + } + testutils.AssertBytesEqual( + t, + hexToSlice(test.expectedOutputScript), + outputScript, + ) + + outputKey, err := bitcoin.TaprootOutputKey( + *d.WalletXOnlyPublicKey, + &merkleRoot, + ) + if err != nil { + t.Fatal(err) + } + testutils.AssertBytesEqual(t, hexToSlice(test.expectedTaprootKey), outputKey[:]) + }) + } +} diff --git a/pkg/tbtc/dkg.go b/pkg/tbtc/dkg.go index 177e225a18..51b9723cc3 100644 --- a/pkg/tbtc/dkg.go +++ b/pkg/tbtc/dkg.go @@ -91,16 +91,24 @@ func newDkgExecutor( scheduler *generator.Scheduler, waitForBlockFn waitForBlockFn, ) *dkgExecutor { - tecdsaExecutor := dkg.NewExecutor( - logger, - scheduler, - workPersistence, - config.PreParamsPoolSize, - config.PreParamsGenerationTimeout, - config.PreParamsGenerationDelay, - config.PreParamsGenerationConcurrency, - config.KeyGenerationConcurrency, - ) + var tecdsaExecutor *dkg.Executor + if config.PreParamsPoolSize > 0 { + tecdsaExecutor = dkg.NewExecutor( + logger, + scheduler, + workPersistence, + config.PreParamsPoolSize, + config.PreParamsGenerationTimeout, + config.PreParamsGenerationDelay, + config.PreParamsGenerationConcurrency, + config.KeyGenerationConcurrency, + ) + } else { + logger.Info( + "ECDSA DKG pre-parameters pool is disabled; " + + "ECDSA DKG execution will be skipped", + ) + } return &dkgExecutor{ groupParameters: groupParameters, @@ -126,6 +134,10 @@ func (de *dkgExecutor) setMetricsRecorder(recorder interface { // preParamsCount returns the current count of the ECDSA DKG pre-parameters. func (de *dkgExecutor) preParamsCount() int { + if de.tecdsaExecutor == nil { + return 0 + } + return de.tecdsaExecutor.PreParamsCount() } @@ -145,6 +157,11 @@ func (de *dkgExecutor) executeDkgIfEligible( zap.String("seed", fmt.Sprintf("0x%x", seed)), ) + if de.tecdsaExecutor == nil { + dkgLogger.Info("ECDSA DKG execution is disabled") + return + } + dkgLogger.Info("checking eligibility for DKG") memberIndexes, groupSelectionResult, err := de.checkEligibility( dkgLogger, @@ -521,11 +538,17 @@ func (de *dkgExecutor) registerSigner( ) } + signerMaterial, err := resolveSignerMaterial(result.PrivateKeyShare) + if err != nil { + return nil, fmt.Errorf("failed to resolve signer material: [%w]", err) + } + signer := newSigner( result.PrivateKeyShare.PublicKey(), finalSigningGroupOperators, finalSigningGroupMemberIndex, result.PrivateKeyShare, + signerMaterial, ) err = de.walletRegistry.registerSigner(signer) diff --git a/pkg/tbtc/dkg_loop.go b/pkg/tbtc/dkg_loop.go index 4b7955abc9..b5b8c7b1cb 100644 --- a/pkg/tbtc/dkg_loop.go +++ b/pkg/tbtc/dkg_loop.go @@ -11,8 +11,8 @@ import ( "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/retry" "github.com/keep-network/keep-core/pkg/tecdsa/dkg" - "github.com/keep-network/keep-core/pkg/tecdsa/retry" "golang.org/x/exp/slices" ) @@ -199,6 +199,7 @@ func (drl *dkgRetryLoop) start( drl.memberIndex, fmt.Sprintf("%v-%v", drl.seed, drl.attemptCounter), ) + cancelAnnounceCtx() if err != nil { drl.logger.Warnf( "[member:%v] announcement for attempt [%v] "+ diff --git a/pkg/tbtc/dkg_test.go b/pkg/tbtc/dkg_test.go index b177e03d10..1da4ffe6dd 100644 --- a/pkg/tbtc/dkg_test.go +++ b/pkg/tbtc/dkg_test.go @@ -15,6 +15,7 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/internal/tecdsatest" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/net/local" @@ -24,6 +25,37 @@ import ( "github.com/keep-network/keep-core/pkg/tecdsa/dkg" ) +func TestDkgExecutor_DisablesECDSAPreParamsWhenPoolSizeZero(t *testing.T) { + executor := newDkgExecutor( + &GroupParameters{}, + nil, + "", + nil, + nil, + nil, + nil, + Config{PreParamsPoolSize: 0}, + nil, + &generator.Scheduler{}, + nil, + ) + + if executor.tecdsaExecutor != nil { + t.Fatal("expected ECDSA DKG executor to be disabled") + } + + testutils.AssertIntsEqual( + t, + "ECDSA pre-parameters count", + 0, + executor.preParamsCount(), + ) + + // An explicit zero pre-parameters pool disables the legacy ECDSA DKG path. + // This should be a no-op and must not require chain/network dependencies. + executor.executeDkgIfEligible(big.NewInt(1), 0, 0) +} + func TestDkgExecutor_RegisterSigner(t *testing.T) { testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) if err != nil { diff --git a/pkg/tbtc/frost_dkg_chain.go b/pkg/tbtc/frost_dkg_chain.go new file mode 100644 index 0000000000..94a168fb9f --- /dev/null +++ b/pkg/tbtc/frost_dkg_chain.go @@ -0,0 +1,103 @@ +package tbtc + +import ( + "math/big" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/registry" + "github.com/keep-network/keep-core/pkg/subscription" +) + +// FrostDKGChain defines the FROST wallet-registry chain surface. It is kept +// separate from the legacy ECDSA DKG chain so the existing coordinator remains +// unchanged until FROST creation is explicitly enabled. +type FrostDKGChain interface { + FrostWalletRegistryAvailable() bool + + OnBridgeNewWalletRequested( + func(event *BridgeNewWalletRequestedEvent), + ) subscription.EventSubscription + + OnFrostDKGStarted( + func(event *FrostDKGStartedEvent), + ) subscription.EventSubscription + PastFrostDKGStartedEvents( + filter *FrostDKGStartedEventFilter, + ) ([]*FrostDKGStartedEvent, error) + + OnFrostDKGResultSubmitted( + func(event *FrostDKGResultSubmittedEvent), + ) subscription.EventSubscription + PastFrostDKGResultSubmittedEvents( + filter *FrostDKGResultSubmittedEventFilter, + ) ([]*FrostDKGResultSubmittedEvent, error) + OnFrostDKGResultChallenged( + func(event *FrostDKGResultChallengedEvent), + ) subscription.EventSubscription + OnFrostDKGResultApproved( + func(event *FrostDKGResultApprovedEvent), + ) subscription.EventSubscription + + SelectFrostGroup() (*GroupSelectionResult, error) + GetFrostDKGState() (DKGState, error) + IsFrostDKGResultValid(result *registry.Result) (bool, string, error) + CalculateFrostDKGResultDigest( + seed *big.Int, + result *registry.Result, + ) ([32]byte, error) + SubmitFrostDKGResult(result *registry.Result) error + ChallengeFrostDKGResult(result *registry.Result) error + ApproveFrostDKGResult(result *registry.Result) error + FrostDKGParameters() (*DKGParameters, error) +} + +// BridgeNewWalletRequestedEvent represents Bridge.NewWalletRequested. +type BridgeNewWalletRequestedEvent struct { + BlockNumber uint64 +} + +// FrostDKGStartedEvent represents the FrostWalletRegistry.DkgStarted event. +type FrostDKGStartedEvent struct { + Seed *big.Int + BlockNumber uint64 +} + +// FrostDKGStartedEventFilter is a component allowing to filter FROST +// DkgStarted events. +type FrostDKGStartedEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + Seed []*big.Int +} + +// FrostDKGResultSubmittedEvent represents a FROST DKG result submission. +type FrostDKGResultSubmittedEvent struct { + Seed *big.Int + ResultHash DKGChainResultHash + Result *registry.Result + BlockNumber uint64 +} + +// FrostDKGResultSubmittedEventFilter is a component allowing to filter FROST +// DkgResultSubmitted events. +type FrostDKGResultSubmittedEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ResultHash []DKGChainResultHash + Seed []*big.Int +} + +// FrostDKGResultChallengedEvent represents a successful FROST DKG challenge. +type FrostDKGResultChallengedEvent struct { + ResultHash DKGChainResultHash + Challenger chain.Address + Reason string + BlockNumber uint64 +} + +// FrostDKGResultApprovedEvent represents a FROST DKG result approval. +type FrostDKGResultApprovedEvent struct { + ResultHash DKGChainResultHash + Approver chain.Address + BlockNumber uint64 +} diff --git a/pkg/tbtc/frost_dkg_coordinator.go b/pkg/tbtc/frost_dkg_coordinator.go new file mode 100644 index 0000000000..dfea4188b6 --- /dev/null +++ b/pkg/tbtc/frost_dkg_coordinator.go @@ -0,0 +1,577 @@ +package tbtc + +import ( + "context" + "fmt" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func initializeFrostDKGCoordinator( + ctx context.Context, + node *node, + frostChain FrostDKGChain, +) { + if frostChain == nil || !frostChain.FrostWalletRegistryAvailable() { + return + } + + frostDeduplicator := newDeduplicator() + + _ = frostChain.OnBridgeNewWalletRequested( + func(event *BridgeNewWalletRequestedEvent) { + logger.Infof( + "observed Bridge NewWalletRequested event at block [%v]; "+ + "waiting for FROST DkgStarted seed callback", + event.BlockNumber, + ) + }, + ) + + _ = frostChain.OnFrostDKGStarted(func(event *FrostDKGStartedEvent) { + go handleFrostDKGStarted( + ctx, + node, + frostChain, + frostDeduplicator, + event, + true, + ) + }) + + _ = frostChain.OnFrostDKGResultSubmitted( + func(event *FrostDKGResultSubmittedEvent) { + go handleFrostDKGResultSubmitted( + ctx, + node, + frostChain, + frostDeduplicator, + event, + ) + }, + ) + + go recoverFrostDKGCoordinatorState(ctx, node, frostChain, frostDeduplicator) +} + +func handleFrostDKGStarted( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + deduplicator *deduplicator, + event *FrostDKGStartedEvent, + waitForConfirmation bool, +) { + if ok := deduplicator.notifyDKGStarted(event.Seed); !ok { + logger.Infof( + "FROST DKG started event with seed [0x%x] has already been processed", + event.Seed, + ) + return + } + + if waitForConfirmation { + confirmationBlock := event.BlockNumber + dkgStartedConfirmationBlocks + logger.Infof( + "observed FROST DKG started event with seed [0x%x] and "+ + "starting block [%v]; waiting for block [%v] to confirm", + event.Seed, + event.BlockNumber, + confirmationBlock, + ) + + if err := node.waitForBlockHeight(ctx, confirmationBlock); err != nil { + logger.Errorf("failed to confirm FROST DKG started event: [%v]", err) + return + } + } + + dkgState, err := frostChain.GetFrostDKGState() + if err != nil { + logger.Errorf("failed to check FROST DKG state: [%v]", err) + return + } + if dkgState != AwaitingResult { + logger.Infof( + "FROST DKG started event with seed [0x%x] and starting "+ + "block [%v] was not confirmed", + event.Seed, + event.BlockNumber, + ) + return + } + + startBlock := uint64(0) + if event.BlockNumber > dkgStartedConfirmationBlocks { + startBlock = event.BlockNumber - dkgStartedConfirmationBlocks + } + + pastEvents, err := frostChain.PastFrostDKGStartedEvents( + &FrostDKGStartedEventFilter{ + StartBlock: startBlock, + }, + ) + if err != nil { + logger.Errorf("failed to get past FROST DKG started events: [%v]", err) + return + } + if len(pastEvents) == 0 { + logger.Errorf("no past FROST DKG started events") + return + } + + lastEvent := pastEvents[len(pastEvents)-1] + memberIndexes, groupSelectionResult, err := localFrostMembership( + node, + frostChain, + ) + if err != nil { + logger.Errorf("failed to resolve FROST DKG membership: [%v]", err) + return + } + + if len(memberIndexes) == 0 { + logger.Infof( + "FROST DKG with seed [0x%x] at block [%v] selected a group "+ + "that does not include this operator; monitoring only", + lastEvent.Seed, + lastEvent.BlockNumber, + ) + return + } + + executeFrostDKGIfPossible( + ctx, + node, + frostChain, + lastEvent, + memberIndexes, + groupSelectionResult, + ) +} + +func recoverFrostDKGCoordinatorState( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + deduplicator *deduplicator, +) { + state, err := frostChain.GetFrostDKGState() + if err != nil { + logger.Errorf("failed to recover FROST DKG state: [%v]", err) + return + } + + switch state { + case AwaitingResult: + startBlock, err := frostDKGRecoveryStartBlock(node, frostChain) + if err != nil { + logger.Errorf("failed to resolve FROST DKG recovery start block: [%v]", err) + return + } + + events, err := frostChain.PastFrostDKGStartedEvents( + &FrostDKGStartedEventFilter{StartBlock: startBlock}, + ) + if err != nil { + logger.Errorf("failed to recover past FROST DKG started events: [%v]", err) + return + } + if len(events) == 0 { + logger.Warnf("FROST DKG state is AwaitingResult but no DkgStarted event was found") + return + } + + handleFrostDKGStarted( + ctx, + node, + frostChain, + deduplicator, + events[len(events)-1], + false, + ) + + case Challenge: + startBlock, err := frostDKGRecoveryStartBlock(node, frostChain) + if err != nil { + logger.Errorf("failed to resolve FROST DKG recovery start block: [%v]", err) + return + } + + events, err := frostChain.PastFrostDKGResultSubmittedEvents( + &FrostDKGResultSubmittedEventFilter{StartBlock: startBlock}, + ) + if err != nil { + logger.Errorf("failed to recover past FROST DKG result submissions: [%v]", err) + return + } + if len(events) == 0 { + logger.Warnf("FROST DKG state is Challenge but no result submission was found") + return + } + + handleFrostDKGResultSubmitted( + ctx, + node, + frostChain, + deduplicator, + events[len(events)-1], + ) + } +} + +func handleFrostDKGResultSubmitted( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + deduplicator *deduplicator, + event *FrostDKGResultSubmittedEvent, +) { + if ok := deduplicator.notifyDKGResultSubmitted( + event.Seed, + event.ResultHash, + event.BlockNumber, + ); !ok { + logger.Infof( + "FROST DKG result with hash [0x%x] for seed [0x%x] at block [%v] "+ + "has already been processed", + event.ResultHash, + event.Seed, + event.BlockNumber, + ) + return + } + + valid, reason, err := frostChain.IsFrostDKGResultValid(event.Result) + if err != nil { + logger.Errorf( + "failed to validate FROST DKG result [0x%x]: [%v]", + event.ResultHash, + err, + ) + return + } + + if !valid { + logger.Warnf( + "challenging invalid FROST DKG result [0x%x]: [%s]", + event.ResultHash, + reason, + ) + challengeInvalidFrostDKGResult(ctx, node, frostChain, event) + return + } + + memberIndexes, _, err := localFrostMembership(node, frostChain) + if err != nil { + logger.Errorf("failed to resolve local FROST DKG membership: [%v]", err) + return + } + if len(memberIndexes) == 0 { + logger.Infof( + "FROST DKG result [0x%x] is valid; this operator is not in the "+ + "selected group and will not approve", + event.ResultHash, + ) + return + } + + params, err := frostChain.FrostDKGParameters() + if err != nil { + logger.Errorf("failed to get FROST DKG parameters: [%v]", err) + return + } + + challengePeriodEndBlock := event.BlockNumber + params.ChallengePeriodBlocks + approvePrecedencePeriodStartBlock := challengePeriodEndBlock + 1 + approvePeriodStartBlock := approvePrecedencePeriodStartBlock + + params.ApprovePrecedencePeriodBlocks + + for _, currentMemberIndex := range memberIndexes { + memberIndex := currentMemberIndex + var approvalBlock uint64 + if uint64(memberIndex) == event.Result.SubmitterMemberIndex { + approvalBlock = approvePrecedencePeriodStartBlock + } else { + approvalBlock = approvePeriodStartBlock + + uint64(memberIndex-1)*dkgResultApprovalDelayStepBlocks + } + + go scheduleFrostDKGResultApproval( + ctx, + node, + frostChain, + event, + memberIndex, + approvalBlock, + ) + } +} + +func challengeInvalidFrostDKGResult( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + event *FrostDKGResultSubmittedEvent, +) { + for attempt := uint64(1); ; attempt++ { + select { + case <-ctx.Done(): + logger.Errorf( + "stopping FROST DKG challenge confirmation: [%v]", + ctx.Err(), + ) + return + default: + } + + state, err := frostChain.GetFrostDKGState() + if err != nil { + logger.Errorf("failed to check FROST DKG state before challenge: [%v]", err) + return + } + if state != Challenge { + logger.Infof( + "invalid FROST DKG result [0x%x] challenged successfully", + event.ResultHash, + ) + return + } + + if err := frostChain.ChallengeFrostDKGResult(event.Result); err != nil { + state, stateErr := frostChain.GetFrostDKGState() + if stateErr == nil && state != Challenge { + logger.Infof( + "invalid FROST DKG result [0x%x] was challenged by another "+ + "operator", + event.ResultHash, + ) + return + } + + logger.Errorf( + "failed to challenge FROST DKG result [0x%x]: [%v]", + event.ResultHash, + err, + ) + if stateErr != nil { + logger.Errorf( + "failed to check FROST DKG state after challenge error: [%v]", + stateErr, + ) + } + return + } + + currentBlock, err := currentFrostDKGBlock(node) + if err != nil { + logger.Errorf( + "failed to get current block after FROST DKG challenge: [%v]", + err, + ) + return + } + + confirmationBlock := currentBlock + dkgResultChallengeConfirmationBlocks + logger.Infof( + "challenging invalid FROST DKG result [0x%x], attempt [%v]; "+ + "waiting for block [%v] to confirm DKG state", + event.ResultHash, + attempt, + confirmationBlock, + ) + + if err := node.waitForBlockHeight(ctx, confirmationBlock); err != nil { + logger.Errorf( + "failed to wait for FROST DKG challenge confirmation: [%v]", + err, + ) + return + } + if ctx.Err() != nil { + logger.Errorf( + "stopping FROST DKG challenge confirmation: [%v]", + ctx.Err(), + ) + return + } + + state, err = frostChain.GetFrostDKGState() + if err != nil { + logger.Errorf("failed to check FROST DKG state after challenge: [%v]", err) + return + } + if state != Challenge { + logger.Infof( + "invalid FROST DKG result [0x%x] challenged successfully", + event.ResultHash, + ) + return + } + + logger.Infof( + "invalid FROST DKG result [0x%x] still not challenged; retrying", + event.ResultHash, + ) + } +} + +func scheduleFrostDKGResultApproval( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + event *FrostDKGResultSubmittedEvent, + memberIndex group.MemberIndex, + approvalBlock uint64, +) { + logger.Infof( + "FROST DKG result [0x%x] is valid; member [%d] scheduling approval "+ + "at block [%v]", + event.ResultHash, + memberIndex, + approvalBlock, + ) + + if err := node.waitForBlockHeight(ctx, approvalBlock); err != nil { + logger.Errorf( + "member [%d] failed to wait for FROST DKG approval block [%v]: [%v]", + memberIndex, + approvalBlock, + err, + ) + return + } + + state, err := frostChain.GetFrostDKGState() + if err != nil { + logger.Errorf("failed to check FROST DKG state before approval: [%v]", err) + return + } + if state != Challenge { + logger.Infof( + "skipping FROST DKG result [0x%x] approval; current state is [%v]", + event.ResultHash, + state, + ) + return + } + + valid, reason, err := frostChain.IsFrostDKGResultValid(event.Result) + if err != nil { + logger.Errorf( + "failed to revalidate FROST DKG result [0x%x] before approval: [%v]", + event.ResultHash, + err, + ) + return + } + if !valid { + logger.Errorf( + "FROST DKG result [0x%x] became invalid before approval: [%s]", + event.ResultHash, + reason, + ) + return + } + + if err := frostChain.ApproveFrostDKGResult(event.Result); err != nil { + logger.Errorf( + "member [%d] failed to approve FROST DKG result [0x%x]: [%v]", + memberIndex, + event.ResultHash, + err, + ) + } +} + +func localFrostMembership( + node *node, + frostChain FrostDKGChain, +) ([]group.MemberIndex, *GroupSelectionResult, error) { + operatorAddress, err := node.operatorAddress() + if err != nil { + return nil, nil, err + } + + groupSelectionResult, err := frostChain.SelectFrostGroup() + if err != nil { + return nil, nil, fmt.Errorf("failed to select FROST group: [%v]", err) + } + + memberIndexes := make([]group.MemberIndex, 0) + for i, selectedOperatorAddress := range groupSelectionResult.OperatorsAddresses { + if selectedOperatorAddress == operatorAddress { + memberIndexes = append(memberIndexes, group.MemberIndex(i+1)) + } + } + + return memberIndexes, groupSelectionResult, nil +} + +func currentFrostDKGBlock(node *node) (uint64, error) { + blockCounter, err := node.chain.BlockCounter() + if err != nil { + return 0, err + } + + return blockCounter.CurrentBlock() +} + +func frostDKGRecoveryStartBlock( + node *node, + frostChain FrostDKGChain, +) (uint64, error) { + currentBlock, err := currentFrostDKGBlock(node) + if err != nil { + return 0, err + } + + params, err := frostChain.FrostDKGParameters() + if err != nil { + return 0, err + } + + lookBackBlocks, err := frostDKGRecoveryLookBackBlocks( + params, + node.groupParameters, + ) + if err != nil { + return 0, err + } + + return boundedFrostDKGRecoveryStartBlock(currentBlock, lookBackBlocks), nil +} + +func frostDKGRecoveryLookBackBlocks( + params *DKGParameters, + groupParameters *GroupParameters, +) (uint64, error) { + if params == nil { + return 0, fmt.Errorf("FROST DKG parameters are nil") + } + if groupParameters == nil { + return 0, fmt.Errorf("group parameters are nil") + } + + // Bound cold-start eth_getLogs by the live on-chain timing parameters while + // still covering the full lifecycle that may require local action after a + // restart: result submission, challenge, submitter precedence, and delayed + // approval fallback across the full group. + return params.SubmissionTimeoutBlocks + + params.ChallengePeriodBlocks + + params.ApprovePrecedencePeriodBlocks + + uint64(groupParameters.GroupSize)*dkgResultApprovalDelayStepBlocks + + dkgStartedConfirmationBlocks, + nil +} + +func boundedFrostDKGRecoveryStartBlock( + currentBlock uint64, + lookBackBlocks uint64, +) uint64 { + if currentBlock <= lookBackBlocks { + return 0 + } + + return currentBlock - lookBackBlocks +} diff --git a/pkg/tbtc/frost_dkg_coordinator_chain_e2e_frost_native_test.go b/pkg/tbtc/frost_dkg_coordinator_chain_e2e_frost_native_test.go new file mode 100644 index 0000000000..479a446f55 --- /dev/null +++ b/pkg/tbtc/frost_dkg_coordinator_chain_e2e_frost_native_test.go @@ -0,0 +1,714 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package tbtc + +// This test chains the FULL FROST wallet-creation coordinator<->chain flow into +// ONE in-process run: +// +// local chain emits FrostDKGStarted +// -> initializeFrostDKGCoordinator's subscription fires +// -> handleFrostDKGStarted confirms the event and resolves group membership +// -> executeFrostDKGIfPossible announces readiness and runs the REAL +// cgo tbtc-signer DKG (executeFrostDKG -> RunDKGWithSeed) +// -> the assembled result is submitted back through the chain +// (FrostDKGChain.SubmitFrostDKGResult) +// -> the wallet is verified registered on the local chain. +// +// Until now the coordinator<->chain wiring and the real cgo DKG execution were +// tested SEPARATELY (frost_dkg_coordinator_test.go drives the chain plumbing +// with stub results; frost_dkg_execution_frost_native_test.go drives the real +// DKG in isolation). This test is the first that exercises both in one flow. +// +// WHAT IS REAL vs REDUCED +// +// - REAL: the DKG output. executeFrostDKG calls the process-global cgo +// tbtc-signer engine (buildTaggedTBTCSignerEngine, registered via +// RegisterNativeExecutionFFISigningPrimitiveForBuild). The x-only group key +// that lands on-chain is the exact key the engine produced - captured by a +// thin recording wrapper and compared byte-for-byte with the submission, so +// no fake/injected result can pass. +// - REAL: the submission path. The result is submitted through the +// FrostDKGChain interface (SubmitFrostDKGResult), not injected into chain +// state directly. +// - REAL: the coordinator wiring. The event is delivered through the +// OnFrostDKGStarted subscription registered by initializeFrostDKGCoordinator; +// confirmation, state check, past-event lookup, membership resolution, +// readiness announcement, result assembly, DKG-result operator-signature +// collection, and delayed submission all run as in production. +// - REDUCED (documented): the group is a 3-of-... group whose 3 seats are all +// held by ONE operator/node. The cgo engine is a process-global +// OnceLock, so N independent real-custody participants cannot run +// concurrently in one OS process. The tbtc-signer development dealer DKG by +// design has a single engine hold every participant's key package, which is +// exactly this shape: one node drives an n>=2 real DKG (the cgo library +// rejects n==1: "participants must contain at least 2 entries"). The fully +// live rehearsal (N node processes vs a real chain with staked operators and +// sortition) is out of scope here - see the PR "Not covered / follow-up". + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/registry" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/subscription" +) + +// reduced group: 3 seats, all owned by the single participating node. +const ( + frostE2EGroupSize = 3 + frostE2EGroupQuorum = 2 + frostE2EHonestThreshold = 2 +) + +func TestFrostDKGCoordinatorChainEndToEnd_RealCgo(t *testing.T) { + setupFrostE2ESignerState(t) + + // Register the REAL cgo tbtc-signer engine, then wrap it so we can capture + // the exact DKG output the coordinator submits on-chain. + frostsigning.UnregisterNativeTBTCSignerEngine() + frostsigning.RegisterNativeExecutionFFISigningPrimitiveForBuild() + t.Cleanup(frostsigning.UnregisterNativeTBTCSignerEngine) + + realEngine := frostsigning.CurrentNativeTBTCSignerEngine() + seeded, ok := realEngine.(frostsigning.NativeTBTCSignerSeededDKGEngine) + if realEngine == nil || !ok { + // The build-tagged engine registers whether or not libfrost_tbtc is + // actually linked, so this only guards the trivial "no engine at all" + // case; the real lib-usability probe follows. + if requireFrostCgo() { + t.Fatalf( + "real cgo tbtc-signer seeded DKG engine unavailable "+ + "(last registration error: %v)", + frostsigning.LastNativeRegistrationError(), + ) + } + t.Skip("real cgo tbtc-signer seeded DKG engine unavailable; skipping") + } + + // P1: probe the REAL linked library UP FRONT. The tbtc-signer engine + // registers via build tag even when libfrost_tbtc is absent/stale; the + // missing ABI symbol only surfaces inside a request-taking engine op (which + // funnels through the once-per-process ABI preflight). Exercise that path + // here - BEFORE emitting the event - so an unusable lib SKIPS immediately + // (or FATALs under the require-cgo gate) instead of failing the coordinator + // goroutine and hanging until the 90s deadline. This runs on the raw seeded + // engine so it does not pollute the recording wrapper's captured key. + probeSeed, err := frostDKGSeedHex(randomFrostSeed(t)) + if err != nil { + t.Fatalf("cannot build probe seed: %v", err) + } + // Unique session id per invocation so in-process repeats (-count=N) add a + // fresh probe DKG session rather than conflicting on a fixed one. + _, probeErr := seeded.RunDKGWithSeed( + fmt.Sprintf("frost-e2e-abi-probe-%d-%x", os.Getpid(), randomFrostSeed(t).Bytes()), + []frostsigning.NativeTBTCSignerDKGParticipant{ + {Identifier: 1, PublicKeyHex: frostsigning.NativeTBTCSignerDKGPlaceholderPublicKeyHex(1)}, + {Identifier: 2, PublicKeyHex: frostsigning.NativeTBTCSignerDKGPlaceholderPublicKeyHex(2)}, + }, + 2, + probeSeed, + ) + skipOrFailIfFrostUnavailable(t, "tbtc-signer ABI preflight (RunDKGWithSeed)", probeErr) + + recordingEngine := &recordingSeededDKGEngine{ + NativeTBTCSignerEngine: realEngine, + seeded: seeded, + } + if err := frostsigning.RegisterNativeTBTCSignerEngine(recordingEngine); err != nil { + t.Fatalf("cannot register recording DKG engine: %v", err) + } + + // Stand up the local chain and a real (in-process) net provider, and build + // the node exactly as production does via newNode. + localChain := Connect(time.Millisecond) + localChain.frostWalletRegistryAvailable = true + + operatorAddress, err := localChain.operatorAddress() + if err != nil { + t.Fatalf("cannot resolve operator address: %v", err) + } + + groupParameters := &GroupParameters{ + GroupSize: frostE2EGroupSize, + GroupQuorum: frostE2EGroupQuorum, + HonestThreshold: frostE2EHonestThreshold, + } + + node, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + local.Connect(), + createMockKeyStorePersistence(t), + &mockPersistenceHandle{}, + newTestScheduler(t), + &mockCoordinationProposalGenerator{}, + Config{PreParamsPoolSize: 1, PreParamsGenerationTimeout: time.Hour}, + ) + if err != nil { + t.Fatalf("cannot create node: %v", err) + } + + // The reduced group: 3 seats, every seat mapped to this one operator. + selectedAddresses := make(chain.Addresses, frostE2EGroupSize) + selectedIDs := make(chain.OperatorIDs, frostE2EGroupSize) + for i := 0; i < frostE2EGroupSize; i++ { + selectedAddresses[i] = operatorAddress + selectedIDs[i] = chain.OperatorID(i + 1) + } + + frostChain := &frostE2EChain{ + localChain: localChain, + logf: t.Logf, + state: Idle, + group: &GroupSelectionResult{ + OperatorsIDs: selectedIDs, + OperatorsAddresses: selectedAddresses, + }, + startedHandlers: map[int]func(*FrostDKGStartedEvent){}, + bridgeHandlers: map[int]func(*BridgeNewWalletRequestedEvent){}, + submittedHandlers: map[int]func(*FrostDKGResultSubmittedEvent){}, + submittedCh: make(chan *registry.Result, 1), + recoveryScanned: make(chan struct{}), + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + // (1) wire the coordinator to the chain, exactly as production startup does. + // This also launches recoverFrostDKGCoordinatorState asynchronously. + initializeFrostDKGCoordinator(ctx, node, frostChain) + + // P2: wait until recovery has completed its initial state scan (observing + // IDLE) before flipping the state. Recovery reads GetFrostDKGState exactly + // once at startup; because the chain is IDLE with no past started events, it + // exits as a no-op. Gating the emit on that scan removes any reliance on + // goroutine scheduling: only the OnFrostDKGStarted subscription (which always + // runs the block-confirmation path) can drive this DKG. + select { + case <-frostChain.recoveryScanned: + case <-ctx.Done(): + t.Fatalf("recovery state scan did not complete: %v", ctx.Err()) + } + + // (2) simulate requestNewWallet -> the chain emits FrostDKGStarted with a + // unique seed (keeps the derived cgo session id unique across -count=N). + seed := randomFrostSeed(t) + t.Logf("STEP 2: emitting FrostDKGStarted seed=0x%x", seed) + frostChain.startFrostDKG(seed) + + // (3)+(4) wait for the coordinator to run the real DKG and submit the result + // back through the chain interface. + var submitted *registry.Result + select { + case submitted = <-frostChain.submittedCh: + case <-ctx.Done(): + t.Fatalf("timed out waiting for FROST DKG result submission: %v", ctx.Err()) + } + t.Logf("STEP 4: SubmitFrostDKGResult observed on-chain") + + // P2 (regression guard): prove the OnFrostDKGStarted subscription's + // block-confirmation path actually ran - not the recovery bypass. That path + // blocks on node.waitForBlockHeight(emitBlock + dkgStartedConfirmationBlocks) + // before it can announce and submit, so the submission CANNOT land before + // that block floor. The recovery bypass (handleFrostDKGStarted with + // waitForConfirmation=false) skips the wait and would submit + // dkgStartedConfirmationBlocks sooner. This is a deterministic floor + // (waitForBlockHeight blocks until the height is reached), not a timing race. + frostChain.mu.Lock() + emitBlock := frostChain.emitBlock + submitBlock := frostChain.submitBlock + frostChain.mu.Unlock() + if submitBlock < emitBlock+dkgStartedConfirmationBlocks { + t.Fatalf( + "submission at block %d did not clear the confirmation floor "+ + "(emit %d + %d); the block-confirmation path was NOT exercised", + submitBlock, + emitBlock, + dkgStartedConfirmationBlocks, + ) + } + t.Logf( + "CONFIRMATION PATH: submit block %d >= emit %d + confirmation %d (block-confirmation path exercised)", + submitBlock, + emitBlock, + dkgStartedConfirmationBlocks, + ) + + // --- LOAD-BEARING ASSERTIONS --------------------------------------------- + // The submitted x-only group key must be the EXACT output of the real cgo + // engine (captured independently by the recording wrapper), proving a real + // DKG output - not an injected/fake value - reached the chain via the + // coordinator's submission path. + capturedKey, captured := recordingEngine.capturedOutputKey() + if !captured { + t.Fatal("recording engine never observed a real DKG execution") + } + if submitted.XOnlyOutputKey != capturedKey { + t.Fatalf( + "submitted x-only key does not match the real cgo DKG output\n"+ + "engine: %x\nsubmitted: %x", + capturedKey[:], + submitted.XOnlyOutputKey[:], + ) + } + if submitted.XOnlyOutputKey == (frost.OutputKey{}) { + t.Fatal("submitted x-only key is all-zero") + } + // It must be a genuine secp256k1 x-only point (lifts to a valid curve point). + if _, err := btcec.ParsePubKey( + append([]byte{0x02}, submitted.XOnlyOutputKey[:]...), + ); err != nil { + t.Fatalf("submitted x-only key is not a valid curve point: %v", err) + } + t.Logf( + "LOAD-BEARING: real cgo DKG x-only key %x landed on-chain via the coordinator", + submitted.XOnlyOutputKey[:], + ) + + // (5) the wallet backed by that real key must be registered on the chain. + pubKey, err := frostOutputKeyToECDSAPublicKey(submitted.XOnlyOutputKey) + if err != nil { + t.Fatalf("cannot lift submitted x-only key: %v", err) + } + walletID := DeriveLegacyWalletID(bitcoin.PublicKeyHash(pubKey)) + registered, err := frostChain.IsFrostWalletRegistered(walletID) + if err != nil { + t.Fatalf("cannot check wallet registration: %v", err) + } + if !registered { + t.Fatal("wallet was not registered on-chain after DKG result submission") + } + t.Logf("STEP 5: wallet %x registered on-chain", walletID) + + // Sanity: the result carries the reduced group and no misbehaving members. + if len(submitted.Members) != frostE2EGroupSize { + t.Fatalf( + "unexpected members count: got %d want %d", + len(submitted.Members), + frostE2EGroupSize, + ) + } + if len(submitted.MisbehavedMembersIndices) != 0 { + t.Fatalf( + "unexpected misbehaving members: %v", + submitted.MisbehavedMembersIndices, + ) + } +} + +// recordingSeededDKGEngine wraps the process-global real cgo tbtc-signer engine +// and captures the x-only output key of the DKG the coordinator runs. Every +// method is delegated to the wrapped engine; only RunDKGWithSeed is intercepted +// to record its (real) output. This lets the test assert the on-chain result is +// the exact engine output, never a fake. +type recordingSeededDKGEngine struct { + // NativeTBTCSignerEngine is the real cgo engine; embedding it promotes the + // full base method set (RunDKG, StartSignRound, FinalizeSignRound, + // BuildTaprootTx, VerifySignatureShare). + frostsigning.NativeTBTCSignerEngine + // seeded is the same real engine, used to delegate the intercepted + // RunDKGWithSeed call. + seeded frostsigning.NativeTBTCSignerSeededDKGEngine + + mu sync.Mutex + outputKey frost.OutputKey + captured bool +} + +func (e *recordingSeededDKGEngine) RunDKGWithSeed( + sessionID string, + participants []frostsigning.NativeTBTCSignerDKGParticipant, + threshold uint16, + dkgSeedHex string, +) (*frostsigning.NativeTBTCSignerDKGResult, error) { + result, err := e.seeded.RunDKGWithSeed( + sessionID, + participants, + threshold, + dkgSeedHex, + ) + if err != nil { + return result, err + } + + if outputKey, keyErr := outputKeyFromTBTCSignerDKGResult(result); keyErr == nil { + e.mu.Lock() + e.outputKey = outputKey + e.captured = true + e.mu.Unlock() + } + + return result, err +} + +func (e *recordingSeededDKGEngine) capturedOutputKey() (frost.OutputKey, bool) { + e.mu.Lock() + defer e.mu.Unlock() + return e.outputKey, e.captured +} + +// frostE2EChain is a test-local FrostDKGChain built on top of the package's +// localChain. It emits FrostDKGStarted, exposes GetFrostDKGState / group +// selection, accepts SubmitFrostDKGResult, and records the submitted wallet so +// the test can verify the full lifecycle. It reuses localChain for block +// counting, signing, operator identity, and the wallet registry. +type frostE2EChain struct { + *localChain + + logf func(string, ...any) + + mu sync.Mutex + state DKGState + pastStarted []*FrostDKGStartedEvent + pastSubmitted []*FrostDKGResultSubmittedEvent + group *GroupSelectionResult + submitted *registry.Result + emitBlock uint64 + submitBlock uint64 + startedHandlers map[int]func(*FrostDKGStartedEvent) + bridgeHandlers map[int]func(*BridgeNewWalletRequestedEvent) + submittedHandlers map[int]func(*FrostDKGResultSubmittedEvent) + submittedCh chan *registry.Result + + // recoveryScanned is closed by the FIRST GetFrostDKGState call, which is + // recoverFrostDKGCoordinatorState's initial scan (nothing else queries the + // state before the started event is emitted). The test waits on it to + // guarantee recovery has already observed the IDLE state - making it a + // genuine no-op - before the state is flipped to AwaitingResult, so the DKG + // is driven DETERMINISTICALLY through the OnFrostDKGStarted subscription + + // confirmation path and never through the recovery bypass. See P2. + recoveryScanned chan struct{} + recoveryScanOnce sync.Once +} + +// startFrostDKG simulates the on-chain requestNewWallet -> DkgStarted sequence: +// it flips the DKG state to AwaitingResult, records the started event for the +// coordinator's past-event lookup, and fires the subscription handlers. +func (c *frostE2EChain) startFrostDKG(seed *big.Int) { + block, err := c.blockCounter.CurrentBlock() + if err != nil { + block = 0 + } + + event := &FrostDKGStartedEvent{Seed: seed, BlockNumber: block} + + c.mu.Lock() + c.state = AwaitingResult + c.emitBlock = block + c.pastStarted = append(c.pastStarted, event) + bridgeHandlers := make([]func(*BridgeNewWalletRequestedEvent), 0, len(c.bridgeHandlers)) + for _, handler := range c.bridgeHandlers { + bridgeHandlers = append(bridgeHandlers, handler) + } + startedHandlers := make([]func(*FrostDKGStartedEvent), 0, len(c.startedHandlers)) + for _, handler := range c.startedHandlers { + startedHandlers = append(startedHandlers, handler) + } + c.mu.Unlock() + + for _, handler := range bridgeHandlers { + handler(&BridgeNewWalletRequestedEvent{BlockNumber: block}) + } + for _, handler := range startedHandlers { + handler(event) + } +} + +func (c *frostE2EChain) OnBridgeNewWalletRequested( + handler func(event *BridgeNewWalletRequestedEvent), +) subscription.EventSubscription { + c.mu.Lock() + defer c.mu.Unlock() + + id := generateHandlerID() + c.bridgeHandlers[id] = handler + return subscription.NewEventSubscription(func() { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.bridgeHandlers, id) + }) +} + +func (c *frostE2EChain) OnFrostDKGStarted( + handler func(event *FrostDKGStartedEvent), +) subscription.EventSubscription { + c.mu.Lock() + defer c.mu.Unlock() + + id := generateHandlerID() + c.startedHandlers[id] = handler + return subscription.NewEventSubscription(func() { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.startedHandlers, id) + }) +} + +func (c *frostE2EChain) PastFrostDKGStartedEvents( + _ *FrostDKGStartedEventFilter, +) ([]*FrostDKGStartedEvent, error) { + c.mu.Lock() + defer c.mu.Unlock() + + events := make([]*FrostDKGStartedEvent, len(c.pastStarted)) + copy(events, c.pastStarted) + return events, nil +} + +func (c *frostE2EChain) OnFrostDKGResultSubmitted( + handler func(event *FrostDKGResultSubmittedEvent), +) subscription.EventSubscription { + c.mu.Lock() + defer c.mu.Unlock() + + id := generateHandlerID() + c.submittedHandlers[id] = handler + return subscription.NewEventSubscription(func() { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.submittedHandlers, id) + }) +} + +func (c *frostE2EChain) PastFrostDKGResultSubmittedEvents( + _ *FrostDKGResultSubmittedEventFilter, +) ([]*FrostDKGResultSubmittedEvent, error) { + c.mu.Lock() + defer c.mu.Unlock() + + events := make([]*FrostDKGResultSubmittedEvent, len(c.pastSubmitted)) + copy(events, c.pastSubmitted) + return events, nil +} + +func (c *frostE2EChain) OnFrostDKGResultChallenged( + func(event *FrostDKGResultChallengedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (c *frostE2EChain) OnFrostDKGResultApproved( + func(event *FrostDKGResultApprovedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (c *frostE2EChain) SelectFrostGroup() (*GroupSelectionResult, error) { + return c.group, nil +} + +func (c *frostE2EChain) GetFrostDKGState() (DKGState, error) { + c.mu.Lock() + state := c.state + c.mu.Unlock() + + // The first reader is recoverFrostDKGCoordinatorState's startup scan; signal + // it has observed the state so the test can safely emit afterwards (P2). + c.recoveryScanOnce.Do(func() { close(c.recoveryScanned) }) + + return state, nil +} + +func (c *frostE2EChain) IsFrostDKGResultValid( + *registry.Result, +) (bool, string, error) { + return true, "", nil +} + +func (c *frostE2EChain) CalculateFrostDKGResultDigest( + seed *big.Int, + result *registry.Result, +) ([32]byte, error) { + hash := sha256.New() + if seed != nil { + hash.Write(seed.Bytes()) + } + hash.Write(result.XOnlyOutputKey[:]) + var digest [32]byte + copy(digest[:], hash.Sum(nil)) + return digest, nil +} + +// SubmitFrostDKGResult records the coordinator's real DKG result, registers the +// resulting wallet, transitions the DKG state, notifies subscribers, and signals +// the test. This is the single on-chain entry point the coordinator uses to land +// a result - nothing bypasses it. +func (c *frostE2EChain) SubmitFrostDKGResult(result *registry.Result) error { + pubKey, err := frostOutputKeyToECDSAPublicKey(result.XOnlyOutputKey) + if err != nil { + return fmt.Errorf("submitted x-only key does not lift to a curve point: %w", err) + } + walletPublicKeyHash := bitcoin.PublicKeyHash(pubKey) + walletID := DeriveLegacyWalletID(walletPublicKeyHash) + + block, err := c.blockCounter.CurrentBlock() + if err != nil { + return err + } + + c.mu.Lock() + if c.state != AwaitingResult { + state := c.state + c.mu.Unlock() + return fmt.Errorf("not awaiting FROST DKG result, state=%v", state) + } + c.state = Challenge + c.submitted = result + c.submitBlock = block + resultHash := DKGChainResultHash(sha256.Sum256(result.XOnlyOutputKey[:])) + seed := big.NewInt(0) + if len(c.pastStarted) > 0 { + seed = c.pastStarted[len(c.pastStarted)-1].Seed + } + event := &FrostDKGResultSubmittedEvent{ + Seed: seed, + ResultHash: resultHash, + Result: result, + BlockNumber: block, + } + c.pastSubmitted = append(c.pastSubmitted, event) + submittedHandlers := make([]func(*FrostDKGResultSubmittedEvent), 0, len(c.submittedHandlers)) + for _, handler := range c.submittedHandlers { + submittedHandlers = append(submittedHandlers, handler) + } + c.mu.Unlock() + + // Register the wallet backed by the real DKG output key. + c.setWallet(walletPublicKeyHash, &WalletChainData{ + WalletID: walletID, + State: StateLive, + }) + + if c.logf != nil { + c.logf( + "STEP 3+4: chain received SubmitFrostDKGResult x-only=%x wallet=%x", + result.XOnlyOutputKey[:], + walletID, + ) + } + + for _, handler := range submittedHandlers { + handler(event) + } + + select { + case c.submittedCh <- result: + default: + } + + return nil +} + +func (c *frostE2EChain) ChallengeFrostDKGResult(*registry.Result) error { + return nil +} + +func (c *frostE2EChain) ApproveFrostDKGResult(*registry.Result) error { + return nil +} + +func (c *frostE2EChain) FrostDKGParameters() (*DKGParameters, error) { + return &DKGParameters{ + // Large submission window so block-time based cancellation never races + // the DKG; challenge/approve windows are present but not exercised here. + SubmissionTimeoutBlocks: 100000, + ChallengePeriodBlocks: 15, + ApprovePrecedencePeriodBlocks: 5, + }, nil +} + +func setupFrostE2ESignerState(t *testing.T) { + t.Helper() + t.Setenv("TBTC_SIGNER_PROFILE", "development") + t.Setenv("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false") + + stateKey := make([]byte, 32) + for i := range stateKey { + stateKey[i] = byte(i + 1) + } + t.Setenv("TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX", hex.EncodeToString(stateKey)) + + // Per-PROCESS state path: stable across -count=N (the signer binds its + // process-global state-file lock to the first path) and unique across + // processes. + stateDir := filepath.Join( + os.TempDir(), + fmt.Sprintf("keep-frost-coordinator-e2e-state-%d", os.Getpid()), + ) + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatalf("cannot create signer state dir: %v", err) + } + t.Setenv("TBTC_SIGNER_STATE_PATH", filepath.Join(stateDir, "signer-state")) +} + +func randomFrostSeed(t *testing.T) *big.Int { + t.Helper() + buffer := make([]byte, 30) + if _, err := rand.Read(buffer); err != nil { + t.Fatalf("cannot read random seed: %v", err) + } + seed := new(big.Int).SetBytes(buffer) + if seed.Sign() == 0 { + seed = big.NewInt(1) + } + return seed +} + +// requireFrostCgo mirrors the reference harness's FrostRequireCgoEnvVar gate +// (pkg/frost/signing): the "lib unavailable" outcome is a SKIP unless +// KEEP_CORE_FROST_REQUIRE_CGO is truthy, in which case it is FATAL. +func requireFrostCgo() bool { + return strings.EqualFold( + strings.TrimSpace(os.Getenv("KEEP_CORE_FROST_REQUIRE_CGO")), + "true", + ) +} + +// skipOrFailIfFrostUnavailable mirrors the reference skipFrostUnavailable +// (pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go): a +// missing/stale libfrost_tbtc surfaces as ErrNativeCryptographyUnavailable and +// SKIPS the test (or FATALs under the require-cgo gate); any other error is a +// genuine failure; nil is a no-op. +func skipOrFailIfFrostUnavailable(t *testing.T, op string, err error) { + t.Helper() + if err == nil { + return + } + if errors.Is(err, frostsigning.ErrNativeCryptographyUnavailable) { + if requireFrostCgo() { + t.Fatalf( + "%s: tbtc-signer FFI symbol unavailable but "+ + "KEEP_CORE_FROST_REQUIRE_CGO is set (lib absent, stale, or "+ + "failed to load - the linked libfrost_tbtc must satisfy the "+ + "bridge): %v", + op, + err, + ) + } + t.Skipf( + "%s: linked tbtc-signer FFI symbol unavailable (lib absent or "+ + "stale; rebuild libfrost_tbtc): %v", + op, + err, + ) + } + t.Fatalf("%s: %v", op, err) +} diff --git a/pkg/tbtc/frost_dkg_coordinator_test.go b/pkg/tbtc/frost_dkg_coordinator_test.go new file mode 100644 index 0000000000..9f38c16f8d --- /dev/null +++ b/pkg/tbtc/frost_dkg_coordinator_test.go @@ -0,0 +1,78 @@ +package tbtc + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/frost/registry" +) + +func TestChallengeInvalidFrostDKGResultRetriesUntilStateLeavesChallenge(t *testing.T) { + localChain := Connect(time.Millisecond) + node := &node{chain: localChain} + + frostChain := &retryingFrostDKGChallengeChain{ + state: Challenge, + successOnAttempt: 2, + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), time.Second) + defer cancelCtx() + + challengeInvalidFrostDKGResult( + ctx, + node, + frostChain, + &FrostDKGResultSubmittedEvent{ + ResultHash: [32]byte{0x01}, + Result: ®istry.Result{}, + }, + ) + + if frostChain.challengeCount != 2 { + t.Fatalf( + "unexpected challenge count\nexpected: [2]\nactual: [%d]", + frostChain.challengeCount, + ) + } + + state, err := frostChain.GetFrostDKGState() + if err != nil { + t.Fatalf("unexpected state error: [%v]", err) + } + if state == Challenge { + t.Fatal("expected challenge loop to leave Challenge state") + } +} + +type retryingFrostDKGChallengeChain struct { + FrostDKGChain + + mutex sync.Mutex + state DKGState + challengeCount int + successOnAttempt int +} + +func (rfdgcc *retryingFrostDKGChallengeChain) GetFrostDKGState() (DKGState, error) { + rfdgcc.mutex.Lock() + defer rfdgcc.mutex.Unlock() + + return rfdgcc.state, nil +} + +func (rfdgcc *retryingFrostDKGChallengeChain) ChallengeFrostDKGResult( + *registry.Result, +) error { + rfdgcc.mutex.Lock() + defer rfdgcc.mutex.Unlock() + + rfdgcc.challengeCount++ + if rfdgcc.challengeCount >= rfdgcc.successOnAttempt { + rfdgcc.state = Idle + } + + return nil +} diff --git a/pkg/tbtc/frost_dkg_execution_default.go b/pkg/tbtc/frost_dkg_execution_default.go new file mode 100644 index 0000000000..4cbd02f4aa --- /dev/null +++ b/pkg/tbtc/frost_dkg_execution_default.go @@ -0,0 +1,26 @@ +//go:build !frost_native + +package tbtc + +import ( + "context" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func executeFrostDKGIfPossible( + _ context.Context, + _ *node, + _ FrostDKGChain, + event *FrostDKGStartedEvent, + memberIndexes []group.MemberIndex, + _ *GroupSelectionResult, +) { + logger.Infof( + "FROST DKG with seed [0x%x] selected this operator as member "+ + "indexes [%v], but native FROST DKG execution is unavailable "+ + "in this build", + event.Seed, + memberIndexes, + ) +} diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go new file mode 100644 index 0000000000..220f0a4f27 --- /dev/null +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -0,0 +1,869 @@ +//go:build frost_native + +package tbtc + +import ( + "context" + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + + "github.com/btcsuite/btcd/btcec/v2" + "go.uber.org/zap" + + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/registry" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/net" + protocolannouncer "github.com/keep-network/keep-core/pkg/protocol/announcer" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +const frostDKGResultSubmissionDelayStepBlocks = 30 + +func executeFrostDKGIfPossible( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + event *FrostDKGStartedEvent, + memberIndexes []group.MemberIndex, + groupSelectionResult *GroupSelectionResult, +) { + nativeTBTCSignerEngine := frostsigning.CurrentNativeTBTCSignerEngine() + if nativeTBTCSignerEngine == nil { + logger.Infof( + "FROST DKG with seed [0x%x] selected this operator as member "+ + "indexes [%v], but no native tbtc-signer engine is registered", + event.Seed, + memberIndexes, + ) + return + } + + // Distributed DKG produces signing material usable ONLY via the interactive + // path; with interactive signing disabled, signing would route to the coarse + // path, which cannot reconstruct a distributed key, and every signature would + // fail. Refuse to run rather than create an unsignable wallet, coupling the DKG + // switch to the signing switch. + if !frostsigning.InteractiveSigningOptInEnabled() { + logger.Errorf( + "FROST DKG with seed [0x%x] selected this operator, but the distributed "+ + "DKG requires interactive signing (%s) to be enabled; refusing to run to "+ + "avoid creating a wallet that cannot sign", + event.Seed, + frostsigning.InteractiveSigningOptInEnvVar, + ) + return + } + + membershipValidator := group.NewMembershipValidator( + logger, + groupSelectionResult.OperatorsAddresses, + node.chain.Signing(), + ) + + channelName := fmt.Sprintf("%s-frost-dkg-%s", ProtocolName, event.Seed.Text(16)) + channel, err := node.netProvider.BroadcastChannelFor(channelName) + if err != nil { + logger.Errorf("failed to get FROST DKG broadcast channel: [%v]", err) + return + } + + registerFrostDKGResultSigningUnmarshaller(channel) + protocolannouncer.RegisterUnmarshaller(channel) + + if err := channel.SetFilter(membershipValidator.IsInGroup); err != nil { + logger.Errorf("failed to set FROST DKG broadcast channel filter: [%v]", err) + return + } + + params, err := frostChain.FrostDKGParameters() + if err != nil { + logger.Errorf("failed to get FROST DKG parameters: [%v]", err) + return + } + + signatureThreshold, err := frostDKGSignatureThreshold(node.groupParameters) + if err != nil { + logger.Errorf("invalid FROST DKG group parameters: [%v]", err) + return + } + + fullMembers := frostFullMembers(groupSelectionResult) + dkgTimeoutBlock := event.BlockNumber + params.SubmissionTimeoutBlocks + + go func() { + dkgLogger := logger.With( + zap.String("seed", fmt.Sprintf("0x%x", event.Seed)), + zap.String("memberIndexes", fmt.Sprintf("%v", memberIndexes)), + ) + + node.protocolLatch.Lock() + defer node.protocolLatch.Unlock() + + dkgCtx, cancelDkgCtx := withCancelOnBlock( + ctx, + dkgTimeoutBlock, + node.waitForBlockHeight, + ) + defer cancelDkgCtx() + + sessionID := fmt.Sprintf("%s-%s", channelName, "attempt-1") + + // Capture DKG round messages off the channel BEFORE the readiness barrier below. + // announceFrostDKGReadiness releases every peer once the quorum announces, but a + // node installs its DKG receiver only later, inside executeDistributedFrostDKG. + // A peer released ahead of a slower node can broadcast round-1 before that node + // is receiving; the transport would drop it (no subscriber) and not retransmit, + // stalling the DKG. The prebuffer catches those from before the barrier so they + // are replayed once the receiver is up. + dkgPrebuffer := frostsigning.StartDKGMessagePrebuffer(dkgCtx, channel) + + activeMemberIndexes, misbehavedMembersIndices, err := + announceFrostDKGReadiness( + dkgCtx, + node, + channel, + membershipValidator, + fmt.Sprintf("%v-%v", ProtocolName, "frost-dkg"), + sessionID, + memberIndexes, + len(groupSelectionResult.OperatorsIDs), + ) + if err != nil { + dkgLogger.Errorf("FROST DKG readiness announcement failed: [%v]", err) + return + } + + localActiveMemberIndexes := localActiveFrostMemberIndexes( + memberIndexes, + activeMemberIndexes, + ) + submitterMemberIndex := lowestLocalActiveMemberIndex( + localActiveMemberIndexes, + activeMemberIndexes, + ) + if submitterMemberIndex == 0 { + dkgLogger.Infof( + "skipping FROST DKG result assembly; no local member "+ + "index is active in [%v]", + activeMemberIndexes, + ) + return + } + + tbtcSignerMemberIndexes, err := finalFrostDKGMemberIndexes( + activeMemberIndexes, + groupSelectionResult, + node.groupParameters, + ) + if err != nil { + dkgLogger.Errorf("failed to resolve final FROST DKG member indexes: [%v]", err) + return + } + + executionResult, err := executeDistributedFrostDKG( + dkgCtx, + nativeTBTCSignerEngine, + node, + channel, + activeMemberIndexes, + tbtcSignerMemberIndexes, + localActiveMemberIndexes, + groupSelectionResult, + signatureThreshold, + sessionID, + dkgPrebuffer, + ) + if err != nil { + dkgLogger.Errorf("FROST DKG execution failed: [%v]", err) + return + } + + for _, localMemberIndex := range localActiveMemberIndexes { + if err := registerFrostSignerWithMaterial( + node, + executionResult.outputKey, + executionResult.signerMaterial, + localMemberIndex, + activeMemberIndexes, + groupSelectionResult, + ); err != nil { + dkgLogger.Errorf("failed to register FROST signer: [%v]", err) + return + } + } + + unsignedResult, err := registry.AssembleResult( + uint64(submitterMemberIndex), + executionResult.outputKey, + fullMembers, + misbehavedMembersIndices, + nil, + nil, + ) + if err != nil { + dkgLogger.Errorf("failed to assemble unsigned FROST DKG result: [%v]", err) + return + } + + signedResult, err := signAndCollectFrostDKGResultSignatures( + dkgCtx, + node, + frostChain, + channel, + membershipValidator, + sessionID, + event.Seed, + localActiveMemberIndexes, + activeMemberIndexes, + groupSelectionResult, + unsignedResult, + ) + if err != nil { + dkgLogger.Errorf("failed to collect FROST DKG result signatures: [%v]", err) + return + } + + valid, reason, err := frostChain.IsFrostDKGResultValid(signedResult) + if err != nil { + dkgLogger.Errorf("failed to pre-validate FROST DKG result: [%v]", err) + return + } + if !valid { + dkgLogger.Errorf("assembled FROST DKG result is invalid: [%s]", reason) + return + } + + if err := submitFrostDKGResultWithDelay( + dkgCtx, + node, + frostChain, + submitterMemberIndex, + activeMemberIndexes, + signedResult, + ); err != nil { + dkgLogger.Errorf("failed to submit FROST DKG result: [%v]", err) + return + } + }() +} + +type frostDKGExecutionResult struct { + outputKey frost.OutputKey + signerMaterial *frostsigning.NativeSignerMaterial +} + +func executeFrostDKG( + nativeTBTCSignerEngine frostsigning.NativeTBTCSignerEngine, + event *FrostDKGStartedEvent, + dkgMemberIndexes []group.MemberIndex, + signatureThreshold int, + sessionID string, +) (*frostDKGExecutionResult, error) { + if nativeTBTCSignerEngine != nil { + return executeTBTCSignerFROSTDKG( + nativeTBTCSignerEngine, + event, + dkgMemberIndexes, + signatureThreshold, + sessionID, + ) + } + + return nil, fmt.Errorf("native tbtc-signer engine is unavailable") +} + +// executeDistributedFrostDKG runs a real distributed FROST DKG for this node's +// local seats over the wallet broadcast channel, persists each seat's key package +// as signing material, and returns the shared group output key plus the signer +// material (the same for every local seat; the differing secret key packages live +// in the engine's per-seat session store). It replaces the transitional +// trusted-dealer executeTBTCSignerFROSTDKG/RunDKGWithSeed. +func executeDistributedFrostDKG( + dkgCtx context.Context, + nativeEngine frostsigning.NativeTBTCSignerEngine, + node *node, + channel net.BroadcastChannel, + activeMemberIndexes []group.MemberIndex, + tbtcSignerMemberIndexes []group.MemberIndex, + localActiveMemberIndexes []group.MemberIndex, + groupSelectionResult *GroupSelectionResult, + signatureThreshold int, + sessionID string, + prebuffer *frostsigning.DKGMessagePrebuffer, +) (*frostDKGExecutionResult, error) { + if nativeEngine == nil { + return nil, fmt.Errorf("native tbtc-signer engine is unavailable") + } + distributedEngine, ok := nativeEngine.(frostsigning.NativeTBTCSignerDistributedDKGEngine) + if !ok { + return nil, fmt.Errorf("native tbtc-signer engine does not support distributed DKG") + } + if signatureThreshold <= 0 || signatureThreshold > int(^uint16(0)) { + return nil, fmt.Errorf("invalid tbtc-signer DKG threshold [%d]", signatureThreshold) + } + + // Canonical FROST identifiers over the FULL participant set (the final compact + // DKG member space), matching what the persist op and the signing path expect. + identifierByID := make(map[group.MemberIndex]string, len(tbtcSignerMemberIndexes)) + for _, memberIndex := range tbtcSignerMemberIndexes { + identifierByID[memberIndex] = frostsigning.CanonicalFROSTIdentifier(uint16(memberIndex)) + } + + // Remap this node's local seats from the ORIGINAL sortition space to the FINAL + // compact DKG member space the runner and persist op operate in (the same + // mapping registerFrostSignerWithMaterial uses). finalSigningGroup sorts its + // operating-members argument in place, so pass a copy. + finalSigningGroupOperators, finalSigningGroupMembersIndexes, err := finalSigningGroup( + groupSelectionResult.OperatorsAddresses, + append([]group.MemberIndex{}, activeMemberIndexes...), + node.groupParameters, + ) + if err != nil { + return nil, fmt.Errorf("cannot resolve the final signing group: [%v]", err) + } + localDKGMemberIndexes := make([]group.MemberIndex, 0, len(localActiveMemberIndexes)) + for _, localSeat := range localActiveMemberIndexes { + finalSeat, ok := finalSigningGroupMembersIndexes[localSeat] + if !ok { + return nil, fmt.Errorf("local seat [%v] is missing from the final signing group", localSeat) + } + localDKGMemberIndexes = append(localDKGMemberIndexes, finalSeat) + } + + // The runner broadcasts FINAL compact member indexes as the transport sender, + // so the DKG bus must authenticate them against a validator indexed by the SAME + // final space - not the original sortition-ordered membership (which would + // reject shifted seats when readiness compacts the active set, stalling the + // DKG). finalSigningGroupOperators[i] is the operator for final member i+1. + finalMembershipValidator := group.NewMembershipValidator( + logger, + finalSigningGroupOperators, + node.chain.Signing(), + ) + + // Each seat's per-DKG round-2 sealing key is a fresh ephemeral generated inside + // the orchestration (not this node's operator key), so operator-key material + // never reaches the runner; the operator key stays bound to the channel, which + // authenticates every seat's round-1 broadcast (and the ephemeral key riding + // in it) via finalMembershipValidator. + persistBySeat, err := frostsigning.RunDistributedDKGForSeats( + dkgCtx, + logger, + channel, + finalMembershipValidator, + distributedEngine, + sessionID, + tbtcSignerMemberIndexes, + localDKGMemberIndexes, + identifierByID, + uint16(signatureThreshold), + prebuffer, + ) + if err != nil { + return nil, err + } + + // Every local seat shares the same group key; build the output key + material + // once from any persisted result. + var persisted *frostsigning.NativeTBTCSignerDKGResult + for _, seatResult := range persistBySeat { + persisted = seatResult + break + } + if persisted == nil { + return nil, fmt.Errorf("distributed DKG produced no persisted result") + } + + outputKey, err := outputKeyFromTBTCSignerDKGResult(persisted) + if err != nil { + return nil, err + } + + // A populated participant list + threshold are required for dkg-persisted + // signer material; there is no dealer seed for a distributed DKG. + participants, err := nativeTBTCSignerDKGParticipants(tbtcSignerMemberIndexes) + if err != nil { + return nil, err + } + + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: persisted.KeyGroup, + TaprootOutputKey: hex.EncodeToString(outputKey[:]), + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceDKGPersisted, + DKGParticipants: participants, + DKGThreshold: uint16(signatureThreshold), + }) + if err != nil { + return nil, fmt.Errorf("cannot marshal tbtc-signer material: [%w]", err) + } + + return &frostDKGExecutionResult{ + outputKey: outputKey, + signerMaterial: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, + }, nil +} + +func finalFrostDKGMemberIndexes( + activeMemberIndexes []group.MemberIndex, + groupSelectionResult *GroupSelectionResult, + groupParameters *GroupParameters, +) ([]group.MemberIndex, error) { + if groupSelectionResult == nil { + return nil, fmt.Errorf("group selection result is nil") + } + if groupParameters == nil { + return nil, fmt.Errorf("group parameters are nil") + } + + operatingMembersIndexes := append([]group.MemberIndex{}, activeMemberIndexes...) + _, finalSigningGroupMembersIndexes, err := finalSigningGroup( + groupSelectionResult.OperatorsAddresses, + operatingMembersIndexes, + groupParameters, + ) + if err != nil { + return nil, err + } + + dkgMemberIndexes := make( + []group.MemberIndex, + 0, + len(operatingMembersIndexes), + ) + for _, activeMemberIndex := range operatingMembersIndexes { + finalMemberIndex, ok := + finalSigningGroupMembersIndexes[activeMemberIndex] + if !ok { + return nil, fmt.Errorf( + "active member [%d] is missing final FROST DKG member index", + activeMemberIndex, + ) + } + + dkgMemberIndexes = append(dkgMemberIndexes, finalMemberIndex) + } + + return dkgMemberIndexes, nil +} + +func executeTBTCSignerFROSTDKG( + nativeEngine frostsigning.NativeTBTCSignerEngine, + event *FrostDKGStartedEvent, + dkgMemberIndexes []group.MemberIndex, + signatureThreshold int, + sessionID string, +) (*frostDKGExecutionResult, error) { + if nativeEngine == nil { + return nil, fmt.Errorf("native tbtc-signer engine is unavailable") + } + + seededEngine, ok := nativeEngine.(frostsigning.NativeTBTCSignerSeededDKGEngine) + if !ok { + return nil, fmt.Errorf("native tbtc-signer engine does not support seeded DKG") + } + + dkgSeedHex, err := frostDKGSeedHex(event.Seed) + if err != nil { + return nil, err + } + + participants, err := nativeTBTCSignerDKGParticipants(dkgMemberIndexes) + if err != nil { + return nil, err + } + + if signatureThreshold <= 0 || signatureThreshold > int(^uint16(0)) { + return nil, fmt.Errorf( + "invalid tbtc-signer DKG threshold [%d]", + signatureThreshold, + ) + } + + dkgResult, err := seededEngine.RunDKGWithSeed( + sessionID, + participants, + uint16(signatureThreshold), + dkgSeedHex, + ) + if err != nil { + return nil, fmt.Errorf("tbtc-signer RunDKG failed: [%w]", err) + } + + outputKey, err := outputKeyFromTBTCSignerDKGResult(dkgResult) + if err != nil { + return nil, err + } + + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: dkgResult.KeyGroup, + TaprootOutputKey: hex.EncodeToString(outputKey[:]), + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceDKGPersisted, + DKGSeedHex: dkgSeedHex, + DKGParticipants: participants, + DKGThreshold: uint16(signatureThreshold), + }) + if err != nil { + return nil, fmt.Errorf("cannot marshal tbtc-signer material: [%w]", err) + } + + return &frostDKGExecutionResult{ + outputKey: outputKey, + signerMaterial: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, + }, nil +} + +func nativeTBTCSignerDKGParticipants( + activeMemberIndexes []group.MemberIndex, +) ([]frostsigning.NativeTBTCSignerDKGParticipant, error) { + participants := make( + []frostsigning.NativeTBTCSignerDKGParticipant, + 0, + len(activeMemberIndexes), + ) + + for _, memberIndex := range activeMemberIndexes { + if memberIndex == 0 { + return nil, fmt.Errorf( + "invalid tbtc-signer DKG member index [%d]", + memberIndex, + ) + } + + identifier := uint16(memberIndex) + participants = append( + participants, + frostsigning.NativeTBTCSignerDKGParticipant{ + Identifier: identifier, + PublicKeyHex: frostsigning. + NativeTBTCSignerDKGPlaceholderPublicKeyHex(identifier), + }, + ) + } + + return participants, nil +} + +func frostDKGSeedHex(seed *big.Int) (string, error) { + if seed == nil { + return "", fmt.Errorf("FROST DKG seed is nil") + } + if seed.Sign() < 0 || len(seed.Bytes()) > frost.OutputKeySize { + return "", fmt.Errorf("FROST DKG seed must fit in %d bytes", frost.OutputKeySize) + } + + seedBytes := make([]byte, frost.OutputKeySize) + seed.FillBytes(seedBytes) + + return hex.EncodeToString(seedBytes), nil +} + +func outputKeyFromTBTCSignerDKGResult( + dkgResult *frostsigning.NativeTBTCSignerDKGResult, +) (frost.OutputKey, error) { + if dkgResult == nil { + return frost.OutputKey{}, fmt.Errorf("tbtc-signer DKG result is nil") + } + if dkgResult.KeyGroup == "" { + return frost.OutputKey{}, fmt.Errorf("tbtc-signer DKG key group is empty") + } + + outputKeyBytes, err := frostsigning.TaprootOutputKeyFromTBTCSignerKey( + dkgResult.KeyGroup, + ) + if err != nil { + return frost.OutputKey{}, fmt.Errorf( + "cannot derive tbtc-signer DKG Taproot output key: [%w]", + err, + ) + } + if len(outputKeyBytes) != frost.OutputKeySize { + return frost.OutputKey{}, fmt.Errorf( + "unexpected tbtc-signer DKG output key length [%d]", + len(outputKeyBytes), + ) + } + + var outputKey frost.OutputKey + copy(outputKey[:], outputKeyBytes) + + return outputKey, nil +} + +func announceFrostDKGReadiness( + ctx context.Context, + node *node, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + protocolID string, + sessionID string, + memberIndexes []group.MemberIndex, + groupSize int, +) ( + []group.MemberIndex, + registry.MisbehavedMemberIndices, + error, +) { + blockCounter, err := node.chain.BlockCounter() + if err != nil { + return nil, nil, err + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return nil, nil, err + } + + announcementEndBlock := currentBlock + dkgAttemptAnnouncementActiveBlocks + announceCtx, cancelAnnounceCtx := withCancelOnBlock( + ctx, + announcementEndBlock, + node.waitForBlockHeight, + ) + defer cancelAnnounceCtx() + + announcer := protocolannouncer.New(protocolID, channel, membershipValidator) + activeMemberIndexes, err := announcer.AnnounceMany( + announceCtx, + memberIndexes, + sessionID, + ) + if err != nil { + return nil, nil, err + } + if ctx.Err() != nil { + return nil, nil, ctx.Err() + } + + if len(activeMemberIndexes) < node.groupParameters.GroupQuorum { + return nil, nil, fmt.Errorf( + "FROST DKG readiness quorum not reached: [%d] active members, quorum [%d]", + len(activeMemberIndexes), + node.groupParameters.GroupQuorum, + ) + } + + return activeMemberIndexes, + frostMisbehavedMemberIndices(groupSize, activeMemberIndexes), + nil +} + +func localActiveFrostMemberIndexes( + localMemberIndexes []group.MemberIndex, + activeMemberIndexes []group.MemberIndex, +) []group.MemberIndex { + activeMembersSet := make( + map[group.MemberIndex]struct{}, + len(activeMemberIndexes), + ) + for _, activeMemberIndex := range activeMemberIndexes { + activeMembersSet[activeMemberIndex] = struct{}{} + } + + localActiveMemberIndexes := make( + []group.MemberIndex, + 0, + len(localMemberIndexes), + ) + for _, localMemberIndex := range localMemberIndexes { + if _, ok := activeMembersSet[localMemberIndex]; ok { + localActiveMemberIndexes = append( + localActiveMemberIndexes, + localMemberIndex, + ) + } + } + + return localActiveMemberIndexes +} + +func registerFrostSignerWithMaterial( + node *node, + outputKey frost.OutputKey, + signerMaterial *frostsigning.NativeSignerMaterial, + memberIndex group.MemberIndex, + activeMemberIndexes []group.MemberIndex, + groupSelectionResult *GroupSelectionResult, +) error { + if signerMaterial == nil { + return fmt.Errorf("FROST signer material is nil") + } + + walletPublicKey, err := frostOutputKeyToECDSAPublicKey(outputKey) + if err != nil { + return err + } + + finalSigningGroupOperators, finalSigningGroupMembersIndexes, err := + finalSigningGroup( + groupSelectionResult.OperatorsAddresses, + append([]group.MemberIndex{}, activeMemberIndexes...), + node.groupParameters, + ) + if err != nil { + return fmt.Errorf("failed to resolve final FROST signing group members: [%w]", err) + } + + finalSigningGroupMemberIndex, ok := + finalSigningGroupMembersIndexes[memberIndex] + if !ok { + return fmt.Errorf("failed to resolve final FROST signing group member index") + } + + return node.walletRegistry.registerSigner(newSigner( + walletPublicKey, + finalSigningGroupOperators, + finalSigningGroupMemberIndex, + nil, + signerMaterial, + )) +} + +func submitFrostDKGResultWithDelay( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + memberIndex group.MemberIndex, + activeMemberIndexes []group.MemberIndex, + result *registry.Result, +) error { + rank := -1 + for i, activeMemberIndex := range activeMemberIndexes { + if activeMemberIndex == memberIndex { + rank = i + break + } + } + if rank < 0 { + return fmt.Errorf( + "FROST DKG submitter member [%d] is not in active members [%v]", + memberIndex, + activeMemberIndexes, + ) + } + + blockCounter, err := node.chain.BlockCounter() + if err != nil { + return err + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return err + } + + submissionBlock := currentBlock + + uint64(rank)*frostDKGResultSubmissionDelayStepBlocks + if err := node.waitForBlockHeight(ctx, submissionBlock); err != nil { + return err + } + if ctx.Err() != nil { + return ctx.Err() + } + + state, err := frostChain.GetFrostDKGState() + if err != nil { + return err + } + if state != AwaitingResult { + logger.Infof( + "skipping FROST DKG result submission by member [%d]; current state is [%v]", + memberIndex, + state, + ) + return nil + } + + return frostChain.SubmitFrostDKGResult(result) +} + +func frostOutputKeyToECDSAPublicKey( + outputKey frost.OutputKey, +) (*ecdsa.PublicKey, error) { + compressed := make([]byte, 0, 1+frost.OutputKeySize) + compressed = append(compressed, byte(0x02)) + compressed = append(compressed, outputKey[:]...) + + publicKey, err := btcec.ParsePubKey(compressed) + if err != nil { + return nil, fmt.Errorf("cannot lift x-only FROST output key: [%w]", err) + } + + return &ecdsa.PublicKey{ + Curve: tecdsa.Curve, + X: publicKey.X(), + Y: publicKey.Y(), + }, nil +} + +func frostFullMembers( + groupSelectionResult *GroupSelectionResult, +) registry.FullMembers { + members := make(registry.FullMembers, len(groupSelectionResult.OperatorsIDs)) + for i, operatorID := range groupSelectionResult.OperatorsIDs { + members[i] = uint32(operatorID) + } + + return members +} + +func lowestLocalActiveMemberIndex( + localMemberIndexes []group.MemberIndex, + activeMemberIndexes []group.MemberIndex, +) group.MemberIndex { + activeMembersSet := make( + map[group.MemberIndex]struct{}, + len(activeMemberIndexes), + ) + for _, activeMemberIndex := range activeMemberIndexes { + activeMembersSet[activeMemberIndex] = struct{}{} + } + + var lowest group.MemberIndex + for _, localMemberIndex := range localMemberIndexes { + if _, ok := activeMembersSet[localMemberIndex]; !ok { + continue + } + + if lowest == 0 || localMemberIndex < lowest { + lowest = localMemberIndex + } + } + + return lowest +} + +func frostMisbehavedMemberIndices( + groupSize int, + activeMemberIndexes []group.MemberIndex, +) registry.MisbehavedMemberIndices { + activeMembersSet := make(map[group.MemberIndex]struct{}, len(activeMemberIndexes)) + for _, memberIndex := range activeMemberIndexes { + activeMembersSet[memberIndex] = struct{}{} + } + + misbehavedMembersIndices := make(registry.MisbehavedMemberIndices, 0) + for i := 1; i <= groupSize; i++ { + memberIndex := group.MemberIndex(i) + if _, ok := activeMembersSet[memberIndex]; ok { + continue + } + + misbehavedMembersIndices = append( + misbehavedMembersIndices, + uint8(memberIndex), + ) + } + + return misbehavedMembersIndices +} diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go new file mode 100644 index 0000000000..663dcd3e7e --- /dev/null +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -0,0 +1,363 @@ +//go:build frost_native + +package tbtc + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/registry" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestLowestLocalActiveMemberIndex(t *testing.T) { + testCases := map[string]struct { + local []group.MemberIndex + active []group.MemberIndex + expected group.MemberIndex + }{ + "lowest local slot active": { + local: []group.MemberIndex{2, 4, 6}, + active: []group.MemberIndex{1, 2, 3, 4}, + expected: 2, + }, + "lowest local slot dropped out": { + local: []group.MemberIndex{2, 4, 6}, + active: []group.MemberIndex{1, 3, 4, 6}, + expected: 4, + }, + "no local slot active": { + local: []group.MemberIndex{2, 4}, + active: []group.MemberIndex{1, 3, 5}, + expected: 0, + }, + } + + for name, test := range testCases { + t.Run(name, func(t *testing.T) { + actual := lowestLocalActiveMemberIndex(test.local, test.active) + if actual != test.expected { + t.Fatalf( + "unexpected lowest local active member index\nexpected: [%d]\nactual: [%d]", + test.expected, + actual, + ) + } + }) + } +} + +func TestLocalActiveFrostMemberIndexes(t *testing.T) { + actual := localActiveFrostMemberIndexes( + []group.MemberIndex{5, 2, 4, 9}, + []group.MemberIndex{1, 2, 3, 4, 5}, + ) + + expected := []group.MemberIndex{5, 2, 4} + if len(actual) != len(expected) { + t.Fatalf( + "unexpected local active member indexes length\nexpected: [%d]\nactual: [%d]", + len(expected), + len(actual), + ) + } + for i := range expected { + if actual[i] != expected[i] { + t.Fatalf( + "unexpected local active member index at [%d]\nexpected: [%d]\nactual: [%d]", + i, + expected[i], + actual[i], + ) + } + } +} + +func TestFrostMisbehavedMemberIndices(t *testing.T) { + actual := frostMisbehavedMemberIndices( + 7, + []group.MemberIndex{1, 3, 4, 7}, + ) + + expected := registry.MisbehavedMemberIndices{2, 5, 6} + if len(actual) != len(expected) { + t.Fatalf( + "unexpected misbehaved member indices length\nexpected: [%d]\nactual: [%d]", + len(expected), + len(actual), + ) + } + for i := range expected { + if actual[i] != expected[i] { + t.Fatalf( + "unexpected misbehaved member index at [%d]\nexpected: [%d]\nactual: [%d]", + i, + expected[i], + actual[i], + ) + } + } +} + +func TestOutputKeyFromTBTCSignerDKGResult_AcceptsCompressedKeyGroup( + t *testing.T, +) { + const compressedKey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + const xOnlyKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + outputKey, err := outputKeyFromTBTCSignerDKGResult( + &frostsigning.NativeTBTCSignerDKGResult{ + KeyGroup: compressedKey, + }, + ) + if err != nil { + t.Fatalf("output key: %v", err) + } + + want, _ := hex.DecodeString(xOnlyKey) + if !bytes.Equal(outputKey[:], want) { + t.Fatalf( + "unexpected output key\nexpected: [%x]\nactual: [%x]", + want, + outputKey[:], + ) + } +} + +func TestExecuteFrostDKG_UsesTBTCSignerMaterial(t *testing.T) { + tbtcSignerEngine := &testNativeTBTCSignerSeededDKGEngine{} + + result, err := executeFrostDKG( + tbtcSignerEngine, + &FrostDKGStartedEvent{Seed: big.NewInt(0x1234)}, + []group.MemberIndex{1, 2, 3}, + 2, + "test-session", + ) + if err != nil { + t.Fatalf("unexpected DKG error: [%v]", err) + } + + if !tbtcSignerEngine.runDKGWithSeedCalled { + t.Fatal("expected tbtc-signer DKG engine to be used") + } + if result.signerMaterial == nil { + t.Fatal("expected signer material") + } + assertTBTCSignerDKGParticipantIdentifiers( + t, + tbtcSignerEngine.runDKGWithSeedParticipants, + []uint16{1, 2, 3}, + ) + if result.signerMaterial.Format != + frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + result.signerMaterial.Format, + ) + } + + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(result.signerMaterial.Payload, &payload); err != nil { + t.Fatalf("unexpected signer material payload decode error: [%v]", err) + } + assertTBTCSignerDKGParticipantIdentifiers( + t, + payload.DKGParticipants, + []uint16{1, 2, 3}, + ) +} + +func TestFinalFrostDKGMemberIndexes_NormalizesToFinalSigningGroupIndexes( + t *testing.T, +) { + activeMemberIndexes := []group.MemberIndex{5, 2, 4} + + actual, err := finalFrostDKGMemberIndexes( + activeMemberIndexes, + &GroupSelectionResult{ + OperatorsAddresses: chain.Addresses{ + "0xAA", + "0xBB", + "0xCC", + "0xDD", + "0xEE", + }, + }, + &GroupParameters{ + GroupSize: 5, + GroupQuorum: 3, + HonestThreshold: 2, + }, + ) + if err != nil { + t.Fatalf("unexpected final member index error: [%v]", err) + } + + expected := []group.MemberIndex{1, 2, 3} + if len(actual) != len(expected) { + t.Fatalf( + "unexpected final member indexes count\nexpected: [%d]\nactual: [%d]", + len(expected), + len(actual), + ) + } + for i := range expected { + if actual[i] != expected[i] { + t.Fatalf( + "unexpected final member index at [%d]\nexpected: [%d]\nactual: [%d]", + i, + expected[i], + actual[i], + ) + } + } + + expectedActive := []group.MemberIndex{5, 2, 4} + for i := range expectedActive { + if activeMemberIndexes[i] != expectedActive[i] { + t.Fatalf( + "active member indexes should not be mutated\nexpected: [%v]\nactual: [%v]", + expectedActive, + activeMemberIndexes, + ) + } + } +} + +func TestExecuteFrostDKG_RequiresTBTCSignerMaterial(t *testing.T) { + _, err := executeFrostDKG( + nil, + &FrostDKGStartedEvent{Seed: big.NewInt(0x1234)}, + []group.MemberIndex{1, 2, 3}, + 2, + "test-session", + ) + if err == nil { + t.Fatal("expected missing tbtc-signer engine error") + } + if !strings.Contains(err.Error(), "native tbtc-signer engine is unavailable") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +type testNativeTBTCSignerSeededDKGEngine struct { + runDKGWithSeedCalled bool + runDKGWithSeedParticipants []frostsigning.NativeTBTCSignerDKGParticipant +} + +func (tntsde *testNativeTBTCSignerSeededDKGEngine) RunDKG( + string, + []frostsigning.NativeTBTCSignerDKGParticipant, + uint16, +) (*frostsigning.NativeTBTCSignerDKGResult, error) { + return nil, fmt.Errorf("unseeded RunDKG should not be used") +} + +func (tntsde *testNativeTBTCSignerSeededDKGEngine) RunDKGWithSeed( + sessionID string, + participants []frostsigning.NativeTBTCSignerDKGParticipant, + threshold uint16, + dkgSeedHex string, +) (*frostsigning.NativeTBTCSignerDKGResult, error) { + tntsde.runDKGWithSeedCalled = true + tntsde.runDKGWithSeedParticipants = append( + []frostsigning.NativeTBTCSignerDKGParticipant{}, + participants..., + ) + + if sessionID != "test-session" { + return nil, fmt.Errorf("unexpected session ID: [%s]", sessionID) + } + if len(participants) != 3 { + return nil, fmt.Errorf("unexpected participant count: [%d]", len(participants)) + } + if threshold != 2 { + return nil, fmt.Errorf("unexpected threshold: [%d]", threshold) + } + if dkgSeedHex == "" { + return nil, fmt.Errorf("expected DKG seed") + } + + return &frostsigning.NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil +} + +func (tntsde *testNativeTBTCSignerSeededDKGEngine) StartSignRound( + string, + uint16, + []byte, + string, + []uint16, + *[32]byte, +) (*frostsigning.NativeTBTCSignerRoundState, error) { + return nil, fmt.Errorf("StartSignRound should not be used") +} + +func (tntsde *testNativeTBTCSignerSeededDKGEngine) FinalizeSignRound( + string, + []frostsigning.NativeTBTCSignerRoundContribution, + *[32]byte, +) ([]byte, error) { + return nil, fmt.Errorf("FinalizeSignRound should not be used") +} + +func (tntsde *testNativeTBTCSignerSeededDKGEngine) BuildTaprootTx( + string, + []frostsigning.NativeTBTCSignerTxInput, + []frostsigning.NativeTBTCSignerTxOutput, + *string, +) (*frostsigning.NativeTBTCSignerTxResult, error) { + return nil, fmt.Errorf("BuildTaprootTx should not be used") +} + +func (tntsde *testNativeTBTCSignerSeededDKGEngine) VerifySignatureShare( + string, + []byte, + []byte, + uint16, + *[32]byte, +) (frostsigning.NativeShareVerificationVerdict, error) { + return frostsigning.NativeShareVerdictIndeterminate, + fmt.Errorf("VerifySignatureShare should not be used") +} + +func assertTBTCSignerDKGParticipantIdentifiers( + t *testing.T, + participants []frostsigning.NativeTBTCSignerDKGParticipant, + expected []uint16, +) { + t.Helper() + + if len(participants) != len(expected) { + t.Fatalf( + "unexpected participant count\nexpected: [%d]\nactual: [%d]", + len(expected), + len(participants), + ) + } + + for i := range expected { + if participants[i].Identifier != expected[i] { + t.Fatalf( + "unexpected participant identifier at [%d]\nexpected: [%d]\nactual: [%d]", + i, + expected[i], + participants[i].Identifier, + ) + } + } +} diff --git a/pkg/tbtc/frost_dkg_parameters.go b/pkg/tbtc/frost_dkg_parameters.go new file mode 100644 index 0000000000..987e2b44ab --- /dev/null +++ b/pkg/tbtc/frost_dkg_parameters.go @@ -0,0 +1,26 @@ +package tbtc + +import "fmt" + +func frostDKGSignatureThreshold(groupParameters *GroupParameters) (int, error) { + if groupParameters == nil { + return 0, fmt.Errorf("group parameters are nil") + } + + // The on-chain FROST validator names this value groupThreshold. In keep-core + // group parameters, that is the honest signing threshold, not the ECDSA DKG + // active-participant quorum. + threshold := groupParameters.HonestThreshold + if threshold <= 0 { + return 0, fmt.Errorf("FROST DKG signature threshold must be positive") + } + if threshold > groupParameters.GroupSize { + return 0, fmt.Errorf( + "FROST DKG signature threshold [%d] exceeds group size [%d]", + threshold, + groupParameters.GroupSize, + ) + } + + return threshold, nil +} diff --git a/pkg/tbtc/frost_dkg_parameters_test.go b/pkg/tbtc/frost_dkg_parameters_test.go new file mode 100644 index 0000000000..c97cd6158a --- /dev/null +++ b/pkg/tbtc/frost_dkg_parameters_test.go @@ -0,0 +1,105 @@ +package tbtc + +import "testing" + +func TestFrostDKGSignatureThresholdUsesHonestThreshold(t *testing.T) { + params := &GroupParameters{ + GroupSize: 100, + GroupQuorum: 90, + HonestThreshold: 51, + } + + threshold, err := frostDKGSignatureThreshold(params) + if err != nil { + t.Fatalf("unexpected threshold error: [%v]", err) + } + if threshold != 51 { + t.Fatalf("unexpected threshold\nexpected: [51]\nactual: [%d]", threshold) + } +} + +func TestFrostDKGSignatureThresholdRejectsInvalidParameters(t *testing.T) { + testCases := map[string]*GroupParameters{ + "nil": nil, + "zero threshold": {GroupSize: 100, GroupQuorum: 90, HonestThreshold: 0}, + "above group size": {GroupSize: 3, GroupQuorum: 3, HonestThreshold: 4}, + } + + for name, params := range testCases { + t.Run(name, func(t *testing.T) { + _, err := frostDKGSignatureThreshold(params) + if err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestBoundedFrostDKGRecoveryStartBlock(t *testing.T) { + lookBackBlocks := uint64(13560) + + testCases := map[string]struct { + currentBlock uint64 + expected uint64 + }{ + "below lookback": { + currentBlock: 100, + expected: 0, + }, + "equal lookback": { + currentBlock: lookBackBlocks, + expected: 0, + }, + "one block above lookback": { + currentBlock: lookBackBlocks + 1, + expected: 1, + }, + "above lookback": { + currentBlock: lookBackBlocks + 123, + expected: 123, + }, + } + + for name, test := range testCases { + t.Run(name, func(t *testing.T) { + actual := boundedFrostDKGRecoveryStartBlock( + test.currentBlock, + lookBackBlocks, + ) + if actual != test.expected { + t.Fatalf( + "unexpected start block\nexpected: [%d]\nactual: [%d]", + test.expected, + actual, + ) + } + }) + } +} + +func TestFrostDKGRecoveryLookBackBlocksCoversFullLifecycle(t *testing.T) { + params := &DKGParameters{ + SubmissionTimeoutBlocks: 500, + ChallengePeriodBlocks: 11520, + ApprovePrecedencePeriodBlocks: 20, + } + groupParameters := &GroupParameters{ + GroupSize: 100, + GroupQuorum: 90, + HonestThreshold: 51, + } + + actual, err := frostDKGRecoveryLookBackBlocks(params, groupParameters) + if err != nil { + t.Fatalf("unexpected lookback error: [%v]", err) + } + + expected := uint64(500 + 11520 + 20 + 100*dkgResultApprovalDelayStepBlocks + dkgStartedConfirmationBlocks) + if actual != expected { + t.Fatalf( + "unexpected lookback\nexpected: [%d]\nactual: [%d]", + expected, + actual, + ) + } +} diff --git a/pkg/tbtc/frost_dkg_result_signing.go b/pkg/tbtc/frost_dkg_result_signing.go new file mode 100644 index 0000000000..cfb76d0e0b --- /dev/null +++ b/pkg/tbtc/frost_dkg_result_signing.go @@ -0,0 +1,292 @@ +package tbtc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "sort" + + "github.com/keep-network/keep-core/pkg/frost/registry" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const frostDKGResultSigningMessageTypePrefix = "frost_dkg/result_signing/" + +type frostDKGResultSignatureMessage struct { + SenderIDValue uint32 `json:"senderID"` + SessionID string `json:"sessionID"` + Digest []byte `json:"digest"` + PublicKey []byte `json:"publicKey"` + Signature []byte `json:"signature"` +} + +func (fdrsm *frostDKGResultSignatureMessage) SenderID() group.MemberIndex { + return group.MemberIndex(fdrsm.SenderIDValue) +} + +func (fdrsm *frostDKGResultSignatureMessage) Type() string { + return frostDKGResultSigningMessageTypePrefix + "signature" +} + +func (fdrsm *frostDKGResultSignatureMessage) Marshal() ([]byte, error) { + return json.Marshal(fdrsm) +} + +func (fdrsm *frostDKGResultSignatureMessage) Unmarshal(data []byte) error { + if err := json.Unmarshal(data, fdrsm); err != nil { + return err + } + + if fdrsm.SenderID() == 0 { + return fmt.Errorf("sender ID is zero") + } + if fdrsm.SessionID == "" { + return fmt.Errorf("session ID is empty") + } + if len(fdrsm.Digest) != 32 { + return fmt.Errorf("digest length [%d] is not 32", len(fdrsm.Digest)) + } + if len(fdrsm.PublicKey) == 0 { + return fmt.Errorf("public key is empty") + } + if len(fdrsm.Signature) == 0 { + return fmt.Errorf("signature is empty") + } + + return nil +} + +func registerFrostDKGResultSigningUnmarshaller(channel net.BroadcastChannel) { + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &frostDKGResultSignatureMessage{} + }) +} + +func signAndCollectFrostDKGResultSignatures( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + sessionID string, + seed *big.Int, + localMemberIndexes []group.MemberIndex, + includedMembersIndexes []group.MemberIndex, + groupSelectionResult *GroupSelectionResult, + unsignedResult *registry.Result, +) (*registry.Result, error) { + if unsignedResult == nil { + return nil, fmt.Errorf("unsigned FROST DKG result is nil") + } + + includedMembersSet := make(map[group.MemberIndex]struct{}) + for _, includedMemberIndex := range includedMembersIndexes { + includedMembersSet[includedMemberIndex] = struct{}{} + } + + digest, err := frostChain.CalculateFrostDKGResultDigest(seed, unsignedResult) + if err != nil { + return nil, fmt.Errorf("cannot calculate FROST DKG result digest: [%w]", err) + } + + signing := node.chain.Signing() + ownSignature, err := signing.Sign(digest[:]) + if err != nil { + return nil, fmt.Errorf("cannot sign FROST DKG result digest: [%w]", err) + } + + localMemberIndexesSet := make(map[group.MemberIndex]struct{}) + signatures := make(map[group.MemberIndex][]byte) + for _, localMemberIndex := range localMemberIndexes { + if _, included := includedMembersSet[localMemberIndex]; !included { + continue + } + if _, seen := localMemberIndexesSet[localMemberIndex]; seen { + continue + } + + localMemberIndexesSet[localMemberIndex] = struct{}{} + signatures[localMemberIndex] = append([]byte{}, ownSignature...) + + ownMessage := &frostDKGResultSignatureMessage{ + SenderIDValue: uint32(localMemberIndex), + SessionID: sessionID, + Digest: append([]byte{}, digest[:]...), + PublicKey: append([]byte{}, signing.PublicKey()...), + Signature: append([]byte{}, ownSignature...), + } + if err := channel.Send( + ctx, + ownMessage, + net.BackoffRetransmissionStrategy, + ); err != nil { + return nil, fmt.Errorf("cannot broadcast FROST DKG result signature: [%w]", err) + } + } + + expectedSignaturesCount, err := frostDKGSignatureThreshold(node.groupParameters) + if err != nil { + return nil, err + } + if expectedSignaturesCount > len(includedMembersIndexes) { + return nil, fmt.Errorf( + "FROST DKG included members count [%d] is below signature threshold [%d]", + len(includedMembersIndexes), + expectedSignaturesCount, + ) + } + + recvCtx, cancelRecvCtx := context.WithCancel(ctx) + defer cancelRecvCtx() + + // Allow a few rounds of duplicate/replayed signatures without blocking the + // network callback while the collector validates and deduplicates messages. + messageChan := make( + chan *frostDKGResultSignatureMessage, + len(includedMembersIndexes)*4+1, + ) + channel.Recv(recvCtx, func(message net.Message) { + payload, ok := message.Payload().(*frostDKGResultSignatureMessage) + if !ok { + return + } + + if !shouldAcceptFrostDKGResultSignatureMessage( + payload, + message.SenderPublicKey(), + sessionID, + localMemberIndexesSet, + includedMembersSet, + membershipValidator, + ) { + return + } + + select { + case messageChan <- payload: + default: + logger.Warnf( + "dropping FROST DKG result signature from member [%d]; collector buffer full", + payload.SenderID(), + ) + } + }) + + for len(signatures) < expectedSignaturesCount { + select { + case <-ctx.Done(): + return nil, fmt.Errorf( + "FROST DKG result signature collection interrupted: [%w]", + ctx.Err(), + ) + case message := <-messageChan: + senderID := message.SenderID() + if !bytes.Equal(message.Digest, digest[:]) { + logger.Warnf( + "dropping FROST DKG result signature from member [%d]; digest mismatch", + senderID, + ) + continue + } + + valid, err := signing.VerifyWithPublicKey( + digest[:], + message.Signature, + message.PublicKey, + ) + if err != nil || !valid { + logger.Warnf( + "dropping invalid FROST DKG result signature from member [%d]: [%v]", + senderID, + err, + ) + continue + } + + expectedOperator := groupSelectionResult.OperatorsAddresses[senderID-1] + actualOperator := signing.PublicKeyBytesToAddress(message.PublicKey) + if actualOperator != expectedOperator { + logger.Warnf( + "dropping FROST DKG result signature from member [%d]; "+ + "operator address [%s] does not match selected operator [%s]", + senderID, + actualOperator, + expectedOperator, + ) + continue + } + + if existing, ok := signatures[senderID]; ok { + if !bytes.Equal(existing, message.Signature) { + logger.Warnf( + "dropping conflicting FROST DKG result signature from member [%d]", + senderID, + ) + } + continue + } + + signatures[senderID] = append([]byte{}, message.Signature...) + } + } + + signingMembersIndices := make([]uint64, 0, len(signatures)) + for memberIndex := range signatures { + signingMembersIndices = append(signingMembersIndices, uint64(memberIndex)) + } + sort.Slice(signingMembersIndices, func(i, j int) bool { + return signingMembersIndices[i] < signingMembersIndices[j] + }) + + packedSignatures := make([]byte, 0) + for _, signingMemberIndex := range signingMembersIndices { + packedSignatures = append( + packedSignatures, + signatures[group.MemberIndex(signingMemberIndex)]..., + ) + } + + return registry.AssembleResult( + unsignedResult.SubmitterMemberIndex, + unsignedResult.XOnlyOutputKey, + unsignedResult.Members, + unsignedResult.MisbehavedMembersIndices, + packedSignatures, + signingMembersIndices, + ) +} + +func shouldAcceptFrostDKGResultSignatureMessage( + message *frostDKGResultSignatureMessage, + senderPublicKey []byte, + sessionID string, + selfMemberIndexesSet map[group.MemberIndex]struct{}, + includedMembersSet map[group.MemberIndex]struct{}, + membershipValidator *group.MembershipValidator, +) bool { + if message == nil { + return false + } + + senderID := message.SenderID() + if senderID == 0 { + return false + } + if _, isSelf := selfMemberIndexesSet[senderID]; isSelf { + return false + } + if message.SessionID != sessionID { + return false + } + if _, included := includedMembersSet[senderID]; !included { + return false + } + if membershipValidator == nil { + return true + } + + return membershipValidator.IsValidMembership(senderID, senderPublicKey) +} diff --git a/pkg/tbtc/frost_dkg_result_signing_test.go b/pkg/tbtc/frost_dkg_result_signing_test.go new file mode 100644 index 0000000000..0d1ff99f96 --- /dev/null +++ b/pkg/tbtc/frost_dkg_result_signing_test.go @@ -0,0 +1,49 @@ +package tbtc + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestShouldAcceptFrostDKGResultSignatureMessageRejectsLocalSeats( + t *testing.T, +) { + includedMembersSet := map[group.MemberIndex]struct{}{ + 1: {}, + 2: {}, + 3: {}, + } + localMembersSet := map[group.MemberIndex]struct{}{ + 1: {}, + 3: {}, + } + + if shouldAcceptFrostDKGResultSignatureMessage( + &frostDKGResultSignatureMessage{ + SenderIDValue: 3, + SessionID: "session", + }, + nil, + "session", + localMembersSet, + includedMembersSet, + nil, + ) { + t.Fatal("expected local member signature message to be rejected") + } + + if !shouldAcceptFrostDKGResultSignatureMessage( + &frostDKGResultSignatureMessage{ + SenderIDValue: 2, + SessionID: "session", + }, + nil, + "session", + localMembersSet, + includedMembersSet, + nil, + ) { + t.Fatal("expected remote included member signature message to be accepted") + } +} diff --git a/pkg/tbtc/frost_signing_backend_guard_test.go b/pkg/tbtc/frost_signing_backend_guard_test.go new file mode 100644 index 0000000000..b37faab34d --- /dev/null +++ b/pkg/tbtc/frost_signing_backend_guard_test.go @@ -0,0 +1,96 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package tbtc + +import ( + "strings" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +// TestVerifyFrostSigningBackend covers the startup guard that prevents a +// FROST-enabled node from starting unless the real native FROST signer engine is +// linked and usable. The legacy backend cannot sign native FROST wallets, and a +// frost_native build not linked with the native signer still registers +// transitional FFI executor / legacy-delegate wrappers - so a non-legacy backend +// name alone is not sufficient. Nodes with FROST disabled are unaffected. +// +// This file is tagged frost_native && frost_tbtc_signer && cgo because it needs +// to add/drop the real native signer engine to exercise the availability paths. +func TestVerifyFrostSigningBackend(t *testing.T) { + // Restore the build-tagged native registrations (adapter, FFI executor, + // signer engine) after the suite so other tests see a linked engine. + t.Cleanup(func() { + frostsigning.ResetExecutionBackend() + frostsigning.RegisterNativeExecutionAdapterForBuild() + }) + + t.Run("FROST disabled: any backend is accepted", func(t *testing.T) { + frostsigning.ResetExecutionBackend() + t.Cleanup(frostsigning.ResetExecutionBackend) + + if err := verifyFrostSigningBackend(false); err != nil { + t.Fatalf("expected no error when FROST is disabled, got [%v]", err) + } + }) + + t.Run("FROST enabled: legacy backend is rejected", func(t *testing.T) { + frostsigning.ResetExecutionBackend() + t.Cleanup(frostsigning.ResetExecutionBackend) + + err := verifyFrostSigningBackend(true) + if err == nil { + t.Fatal("expected an error for the legacy signing backend, got nil") + } + if !strings.Contains(err.Error(), "frostSigningBackend") { + t.Errorf( + "error should point at the tbtc.frostSigningBackend config: [%v]", + err, + ) + } + }) + + t.Run("FROST enabled: native with the linked signer engine is accepted", func(t *testing.T) { + frostsigning.ResetExecutionBackend() + // Register the build's native adapter + FFI + signer engine. + frostsigning.RegisterNativeExecutionAdapterForBuild() + t.Cleanup(func() { + frostsigning.ResetExecutionBackend() + frostsigning.RegisterNativeExecutionAdapterForBuild() + }) + + if err := frostsigning.SetExecutionBackendByName("native"); err != nil { + t.Fatalf("failed to select the native backend: [%v]", err) + } + // In the optional-link dev profile libfrost_tbtc is not linked, so native + // execution is genuinely unavailable and the guard correctly rejects it; + // skip the positive assertion there. + if !frostsigning.NativeExecutionAvailable() { + t.Skip("native signer library not linked in this build profile; native execution is genuinely unavailable") + } + if err := verifyFrostSigningBackend(true); err != nil { + t.Fatalf("expected no error with the native signer engine linked, got [%v]", err) + } + }) + + t.Run("FROST enabled: native without a linked signer engine is rejected", func(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.RegisterNativeExecutionAdapterForBuild() + // Drop only the real signer engine, leaving the transitional wrappers - + // this models a frost_native build not linked with the native signer. + frostsigning.UnregisterNativeTBTCSignerEngine() + t.Cleanup(func() { + frostsigning.ResetExecutionBackend() + frostsigning.RegisterNativeExecutionAdapterForBuild() + }) + + if err := frostsigning.SetExecutionBackendByName("native"); err != nil { + t.Fatalf("failed to select the native backend: [%v]", err) + } + err := verifyFrostSigningBackend(true) + if err == nil { + t.Fatal("expected an error when the native signer engine is not linked, got nil") + } + }) +} diff --git a/pkg/tbtc/heartbeat.go b/pkg/tbtc/heartbeat.go index c86afd88db..64fad1556e 100644 --- a/pkg/tbtc/heartbeat.go +++ b/pkg/tbtc/heartbeat.go @@ -9,8 +9,8 @@ import ( "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" ) const ( @@ -60,7 +60,7 @@ type heartbeatSigningExecutor interface { ctx context.Context, message *big.Int, startBlock uint64, - ) (*tecdsa.Signature, *signingActivityReport, uint64, error) + ) (*frost.Signature, *signingActivityReport, uint64, error) } // heartbeatInactivityClaimExecutor is an interface meant to decouple the diff --git a/pkg/tbtc/heartbeat_test.go b/pkg/tbtc/heartbeat_test.go index c635659a08..7d833f16ab 100644 --- a/pkg/tbtc/heartbeat_test.go +++ b/pkg/tbtc/heartbeat_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" ) func TestHeartbeatAction_HappyPath(t *testing.T) { @@ -612,7 +612,7 @@ func (mhse *mockHeartbeatSigningExecutor) sign( ctx context.Context, message *big.Int, startBlock uint64, -) (*tecdsa.Signature, *signingActivityReport, uint64, error) { +) (*frost.Signature, *signingActivityReport, uint64, error) { mhse.requestedMessage = message mhse.requestedStartBlock = startBlock @@ -636,7 +636,7 @@ func (mhse *mockHeartbeatSigningExecutor) sign( inactiveMembers: inactiveMembers, } - return &tecdsa.Signature{}, activityReport, startBlock + 1, nil + return &frost.Signature{}, activityReport, startBlock + 1, nil } type mockInactivityClaimExecutor struct { diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index 3ee310d21b..babbfb34f7 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -12,6 +12,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" "github.com/keep-network/keep-core/pkg/tecdsa" @@ -42,15 +43,18 @@ func (s *signer) Marshal() ([]byte, error) { SigningGroupOperators: walletSigningGroupOperators, } - privateKeyShare, err := s.privateKeyShare.Marshal() + signerMaterialBytes, err := marshalSignerMaterialForPersistence( + s.signerMaterial, + s.privateKeyShare, + ) if err != nil { - return nil, fmt.Errorf("cannot marshal private key share: [%w]", err) + return nil, fmt.Errorf("cannot marshal signer material: [%w]", err) } return proto.Marshal(&pb.Signer{ Wallet: pbWallet, SigningGroupMemberIndex: uint32(s.signingGroupMemberIndex), - PrivateKeyShare: privateKeyShare, + PrivateKeyShare: signerMaterialBytes, }) } @@ -72,9 +76,11 @@ func (s *signer) Unmarshal(bytes []byte) error { chain.Address(pbSigner.Wallet.SigningGroupOperators[i]) } - privateKeyShare := &tecdsa.PrivateKeyShare{} - if err := privateKeyShare.Unmarshal(pbSigner.PrivateKeyShare); err != nil { - return fmt.Errorf("cannot unmarshal private key share: [%w]", err) + signerMaterial, err := unmarshalSignerMaterialFromPersistence( + pbSigner.PrivateKeyShare, + ) + if err != nil { + return fmt.Errorf("cannot unmarshal signer material: [%w]", err) } s.wallet = wallet{ @@ -82,7 +88,8 @@ func (s *signer) Unmarshal(bytes []byte) error { signingGroupOperators: walletSigningGroupOperators, } s.signingGroupMemberIndex = group.MemberIndex(pbSigner.SigningGroupMemberIndex) - s.privateKeyShare = privateKeyShare + s.privateKeyShare = signerMaterial.privateKeyShare + s.signerMaterial = signerMaterial.signerMaterial return nil } @@ -114,7 +121,7 @@ func (sdm *signingDoneMessage) Unmarshal(bytes []byte) error { return err } - signature := &tecdsa.Signature{} + signature := &frost.Signature{} if err := signature.Unmarshal(pbMsg.Signature); err != nil { return fmt.Errorf("cannot unmarshal signature: [%v]", err) } diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 892d234ecc..c1e750f9ec 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -13,9 +13,9 @@ import ( fuzz "github.com/google/gofuzz" "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/internal/pbutils" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" ) func TestSignerMarshalling(t *testing.T) { @@ -26,9 +26,7 @@ func TestSignerMarshalling(t *testing.T) { if err := pbutils.RoundTrip(marshaled, unmarshaled); err != nil { t.Fatal(err) } - if !reflect.DeepEqual(marshaled, unmarshaled) { - t.Fatal("unexpected content of unmarshaled signer") - } + assertSignerEquivalent(t, "unmarshaled signer", marshaled, unmarshaled) } func TestSignerMarshalling_NonTECDSAKey(t *testing.T) { @@ -53,12 +51,8 @@ func TestSigningDoneMessage_MarshalingRoundtrip(t *testing.T) { senderID: group.MemberIndex(10), message: big.NewInt(100), attemptNumber: 2, - signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 3, - }, - endBlock: 4500, + signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), + endBlock: 4500, } unmarshaled := &signingDoneMessage{} @@ -78,7 +72,7 @@ func TestFuzzSigningDoneMessage_MarshalingRoundtrip(t *testing.T) { senderID group.MemberIndex message big.Int attemptNumber uint64 - signature tecdsa.Signature + signature frost.Signature endBlock uint64 ) diff --git a/pkg/tbtc/moved_funds_sweep.go b/pkg/tbtc/moved_funds_sweep.go index 2569f4557d..a18e53983d 100644 --- a/pkg/tbtc/moved_funds_sweep.go +++ b/pkg/tbtc/moved_funds_sweep.go @@ -1,7 +1,6 @@ package tbtc import ( - "crypto/ecdsa" "fmt" "math/big" "time" @@ -137,7 +136,18 @@ func (mfsa *movedFundsSweepAction) execute() error { walletPublicKeyHash := bitcoin.PublicKeyHash(mfsa.wallet().publicKey) - err := ValidateMovedFundsSweepProposal( + walletOutputScript, err := walletOutputScriptForPublicKeyHash( + mfsa.chain, + walletPublicKeyHash, + ) + if err != nil { + return fmt.Errorf( + "error while resolving moved funds sweep wallet output: [%v]", + err, + ) + } + + err = ValidateMovedFundsSweepProposal( validateProposalLogger, walletPublicKeyHash, mfsa.proposal, @@ -161,8 +171,8 @@ func (mfsa *movedFundsSweepAction) execute() error { } // Prepare the wallet's main UTXO. - walletMainUtxo, err := DetermineWalletMainUtxo( - walletPublicKeyHash, + walletMainUtxo, err := DetermineWalletMainUtxoForPublicKey( + mfsa.wallet().publicKey, mfsa.chain, mfsa.btcChain, ) @@ -173,8 +183,8 @@ func (mfsa *movedFundsSweepAction) execute() error { ) } - err = EnsureWalletSyncedBetweenChains( - walletPublicKeyHash, + err = EnsureWalletSyncedBetweenChainsForPublicKey( + mfsa.wallet().publicKey, walletMainUtxo, mfsa.chain, mfsa.btcChain, @@ -189,9 +199,9 @@ func (mfsa *movedFundsSweepAction) execute() error { unsignedMovedFundsSweepTx, err := assembleMovedFundsSweepTransaction( mfsa.btcChain, - mfsa.wallet().publicKey, movedFundsUtxo, walletMainUtxo, + walletOutputScript, mfsa.proposal.SweepTxFee.Int64(), ) if err != nil { @@ -256,6 +266,16 @@ func assembleMovedFundsSweepUtxo( ) } + if movingFundsTxOutputIdx >= uint32(len(movingFundsTx.Outputs)) { + movingFundsTxHashForDisplay := bitcoin.Hash(movingFundsTxHash) + return nil, fmt.Errorf( + "output index [%d] out of range for transaction [%s] "+ + "with [%d] outputs", + movingFundsTxOutputIdx, + movingFundsTxHashForDisplay.Hex(bitcoin.InternalByteOrder), + len(movingFundsTx.Outputs), + ) + } movingFundsTxValue := movingFundsTx.Outputs[movingFundsTxOutputIdx].Value return &bitcoin.UnspentTransactionOutput{ @@ -309,19 +329,23 @@ func ValidateMovedFundsSweepProposal( func assembleMovedFundsSweepTransaction( bitcoinChain bitcoin.Chain, - walletPublicKey *ecdsa.PublicKey, movedFundsUtxo *bitcoin.UnspentTransactionOutput, walletMainUtxo *bitcoin.UnspentTransactionOutput, + walletOutputScript bitcoin.Script, fee int64, ) (*bitcoin.TransactionBuilder, error) { if movedFundsUtxo == nil { return nil, fmt.Errorf("moved funds UTXO is required") } + if walletOutputScript == nil { + return nil, fmt.Errorf("wallet output script is required") + } + builder := bitcoin.NewTransactionBuilder(bitcoinChain) // The moved funds UTXO is always the first input. - err := builder.AddPublicKeyHashInput(movedFundsUtxo) + err := addWalletUtxoInput(builder, bitcoinChain, movedFundsUtxo) if err != nil { return nil, fmt.Errorf( "cannot add input pointing to moved funds UTXO: [%v]", @@ -331,7 +355,7 @@ func assembleMovedFundsSweepTransaction( // If the wallet has the main UTXO it becomes the second input. if walletMainUtxo != nil { - err := builder.AddPublicKeyHashInput(walletMainUtxo) + err := addWalletUtxoInput(builder, bitcoinChain, walletMainUtxo) if err != nil { return nil, fmt.Errorf( "cannot add input pointing to main wallet UTXO: [%v]", @@ -343,16 +367,9 @@ func assembleMovedFundsSweepTransaction( // Add a single output transferring funds to the wallet itself. outputValue := builder.TotalInputsValue() - fee - outputScript, err := bitcoin.PayToWitnessPublicKeyHash( - bitcoin.PublicKeyHash(walletPublicKey), - ) - if err != nil { - return nil, fmt.Errorf("cannot compute output script: [%v]", err) - } - builder.AddOutput(&bitcoin.TransactionOutput{ Value: outputValue, - PublicKeyScript: outputScript, + PublicKeyScript: walletOutputScript, }) return builder, nil diff --git a/pkg/tbtc/moved_funds_sweep_test.go b/pkg/tbtc/moved_funds_sweep_test.go index 68ae7be032..d296e6d84f 100644 --- a/pkg/tbtc/moved_funds_sweep_test.go +++ b/pkg/tbtc/moved_funds_sweep_test.go @@ -4,13 +4,13 @@ import ( "context" "fmt" "math/big" + "strings" "testing" "time" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/tbtc/internal/test" ) @@ -78,16 +78,15 @@ func TestMovedFundsSweepAction_Execute(t *testing.T) { // Create a signing executor mock instance. signingExecutor := newMockWalletSigningExecutor() - // The signatures within the scenario fixture are in the format - // suitable for applying them directly to a Bitcoin transaction. - // However, the signing executor operates on raw tECDSA signatures - // so, we need to unpack them first. - rawSignatures := make([]*tecdsa.Signature, len(scenario.Signatures)) + // The signatures within the scenario fixture are represented as + // big integer components and need conversion to runtime signature + // containers used by signing executor. + rawSignatures := make([]*frost.Signature, len(scenario.Signatures)) for i, signature := range scenario.Signatures { - rawSignatures[i] = &tecdsa.Signature{ - R: signature.R, - S: signature.S, - } + rawSignatures[i] = mustFrostSignatureFromBigInts( + signature.R, + signature.S, + ) } // Set up the signing executor mock to return the signatures from @@ -160,11 +159,18 @@ func TestAssembleMovedFundsSweepTransaction(t *testing.T) { } } + walletOutputScript, err := bitcoin.PayToWitnessPublicKeyHash( + bitcoin.PublicKeyHash(scenario.WalletPublicKey), + ) + if err != nil { + t.Fatal(err) + } + builder, err := assembleMovedFundsSweepTransaction( bitcoinChain, - scenario.WalletPublicKey, scenario.MovedFundsUtxo, scenario.WalletMainUtxo, + walletOutputScript, scenario.Fee, ) @@ -211,3 +217,121 @@ func TestAssembleMovedFundsSweepTransaction(t *testing.T) { }) } } + +func TestAssembleMovedFundsSweepTransaction_SupportsTaprootUtxos( + t *testing.T, +) { + bitcoinChain := newLocalBitcoinChain() + walletPublicKey := testWalletPublicKeyFromXOnly( + t, + "2336f65004d8f122f1fe947ebd009a8b4add3a0d937356d568e30f7fcc2e4008", + ) + walletMainUtxo := testTaprootWalletMainUtxo( + t, + bitcoinChain, + walletPublicKey, + ) + + walletXOnlyPublicKey, err := walletXOnlyPublicKey(walletPublicKey) + if err != nil { + t.Fatal(err) + } + + walletOutputScript, err := bitcoin.PayToTaproot(walletXOnlyPublicKey) + if err != nil { + t.Fatal(err) + } + + var previousTxHash bitcoin.Hash + previousTxHash[0] = 0x02 + movingFundsTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: previousTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: walletOutputScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(movingFundsTx); err != nil { + t.Fatal(err) + } + + builder, err := assembleMovedFundsSweepTransaction( + bitcoinChain, + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: movingFundsTx.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + walletMainUtxo, + walletOutputScript, + 1000, + ) + if err != nil { + t.Fatal(err) + } + + if !builder.HasOnlyTaprootKeyPathInputs() { + t.Fatal("expected moved funds sweep builder to use Taproot key-path inputs") + } +} + +func TestAssembleMovedFundsSweepUtxo_RejectsOutOfRangeOutputIndex(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + var previousTxHash bitcoin.Hash + previousTxHash[0] = 0x03 + + outputScript, err := bitcoin.PayToWitnessPublicKeyHash( + hexToByte20("c7302d75072d78be94eb8d36c4b77583c7abb06e"), + ) + if err != nil { + t.Fatal(err) + } + + movingFundsTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: previousTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: outputScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(movingFundsTx); err != nil { + t.Fatal(err) + } + + _, err = assembleMovedFundsSweepUtxo( + bitcoinChain, + movingFundsTx.Hash(), + 1, + ) + if err == nil { + t.Fatal("expected out-of-range output index error") + } + if !strings.Contains(err.Error(), "output index [1] out of range") { + t.Fatalf("unexpected error: [%v]", err) + } +} diff --git a/pkg/tbtc/moving_funds.go b/pkg/tbtc/moving_funds.go index 1e9c01b0a3..8c4a1d2534 100644 --- a/pkg/tbtc/moving_funds.go +++ b/pkg/tbtc/moving_funds.go @@ -133,8 +133,8 @@ func (mfa *movingFundsAction) execute() error { walletPublicKeyHash := bitcoin.PublicKeyHash(mfa.wallet().publicKey) - walletMainUtxo, err := DetermineWalletMainUtxo( - walletPublicKeyHash, + walletMainUtxo, err := DetermineWalletMainUtxoForPublicKey( + mfa.wallet().publicKey, mfa.chain, mfa.btcChain, ) @@ -188,8 +188,8 @@ func (mfa *movingFundsAction) execute() error { return fmt.Errorf("validate proposal step failed: [%v]", err) } - err = EnsureWalletSyncedBetweenChains( - walletPublicKeyHash, + err = EnsureWalletSyncedBetweenChainsForPublicKey( + mfa.wallet().publicKey, walletMainUtxo, mfa.chain, mfa.btcChain, @@ -202,10 +202,21 @@ func (mfa *movingFundsAction) execute() error { ) } + targetWalletOutputScripts, err := walletOutputScriptsForPublicKeyHashes( + mfa.chain, + mfa.proposal.TargetWallets, + ) + if err != nil { + return fmt.Errorf( + "error while resolving moving funds target wallet outputs: [%v]", + err, + ) + } + unsignedMovingFundsTx, err := assembleMovingFundsTransaction( mfa.btcChain, walletMainUtxo, - mfa.proposal.TargetWallets, + targetWalletOutputScripts, mfa.proposal.MovingFundsTxFee.Int64(), ) if err != nil { @@ -563,10 +574,10 @@ func isWalletPendingMovingFundsTarget( func assembleMovingFundsTransaction( bitcoinChain bitcoin.Chain, walletMainUtxo *bitcoin.UnspentTransactionOutput, - targetWallets [][20]byte, + targetWalletOutputScripts []bitcoin.Script, fee int64, ) (*bitcoin.TransactionBuilder, error) { - if len(targetWallets) < 1 { + if len(targetWalletOutputScripts) < 1 { return nil, fmt.Errorf("at least one target wallet is required") } @@ -575,7 +586,7 @@ func assembleMovingFundsTransaction( } builder := bitcoin.NewTransactionBuilder(bitcoinChain) - err := builder.AddPublicKeyHashInput(walletMainUtxo) + err := addWalletUtxoInput(builder, bitcoinChain, walletMainUtxo) if err != nil { return nil, fmt.Errorf( "cannot add input pointing to wallet main UTXO: [%v]", @@ -584,7 +595,7 @@ func assembleMovingFundsTransaction( } // The number of target wallets. It determines the number of outputs. - targetWalletsCount := int64(len(targetWallets)) + targetWalletsCount := int64(len(targetWalletOutputScripts)) // The sum of all the outputs equal to the input value minus fee. totalOutputValue := walletMainUtxo.Value - fee @@ -603,16 +614,9 @@ func assembleMovingFundsTransaction( // should be the same as during the commitment. We don't to check it, // as the order of target wallets has already been validated by the on-chain // contract. - for i, targetWalletPublicKeyHash := range targetWallets { - outputScript, err := bitcoin.PayToWitnessPublicKeyHash( - targetWalletPublicKeyHash, - ) - if err != nil { - return nil, fmt.Errorf("cannot compute output script: [%v]", err) - } - + for i, targetWalletOutputScript := range targetWalletOutputScripts { outputValue := singleOutputValue - if i == len(targetWallets)-1 { + if i == len(targetWalletOutputScripts)-1 { // If we are at the last output, increase its value by the remaining // satoshis. If the total output amount is divisible by the number // of target wallets, the increase will be `0`. @@ -621,7 +625,7 @@ func assembleMovingFundsTransaction( builder.AddOutput(&bitcoin.TransactionOutput{ Value: outputValue, - PublicKeyScript: outputScript, + PublicKeyScript: targetWalletOutputScript, }) } diff --git a/pkg/tbtc/moving_funds_test.go b/pkg/tbtc/moving_funds_test.go index d1fb2b99d4..566835ba05 100644 --- a/pkg/tbtc/moving_funds_test.go +++ b/pkg/tbtc/moving_funds_test.go @@ -11,8 +11,8 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/tbtc/internal/test" - "github.com/keep-network/keep-core/pkg/tecdsa" ) // TODO: Think about covering unhappy paths for specific steps of the moving funds action. @@ -88,18 +88,20 @@ func TestMovingFundsAction_Execute(t *testing.T) { MainUtxoHash: walletMainUtxoHash, MovingFundsTargetWalletsCommitmentHash: movingFundsCommitmentHash, }) + for _, targetWallet := range scenario.TargetWallets { + hostChain.setWallet(targetWallet, &WalletChainData{}) + } // Create a signing executor mock instance. signingExecutor := newMockWalletSigningExecutor() - // The signature within the scenario fixture is in the format - // suitable for applying them directly to a Bitcoin transaction. - // However, the signing executor operates on raw tECDSA signatures - // so, we need to unpack it first. - rawSignature := &tecdsa.Signature{ - R: scenario.Signature.R, - S: scenario.Signature.S, - } + // The signature within the scenario fixture is represented as + // big integer components and needs conversion to runtime signature + // container used by signing executor. + rawSignature := mustFrostSignatureFromBigInts( + scenario.Signature.R, + scenario.Signature.S, + ) // Set up the signing executor mock to return the signature from // the test fixture when called with the expected parameters. @@ -108,7 +110,7 @@ func TestMovingFundsAction_Execute(t *testing.T) { signingExecutor.setSignatures( []*big.Int{scenario.ExpectedSigHash}, proposalProcessingStartBlock+movingFundsCommitmentConfirmationBlocks, - []*tecdsa.Signature{rawSignature}, + []*frost.Signature{rawSignature}, ) action := newMovingFundsAction( @@ -169,10 +171,17 @@ func TestAssembleMovingFundsTransaction(t *testing.T) { t.Fatal(err) } + targetWalletOutputScripts, err := testLegacyWalletOutputScripts( + scenario.TargetWallets, + ) + if err != nil { + t.Fatal(err) + } + builder, err := assembleMovingFundsTransaction( bitcoinChain, scenario.WalletMainUtxo, - scenario.TargetWallets, + targetWalletOutputScripts, scenario.Fee, ) @@ -227,6 +236,44 @@ func TestAssembleMovingFundsTransaction(t *testing.T) { } } +func TestAssembleMovingFundsTransaction_SupportsTaprootWalletMainUtxo( + t *testing.T, +) { + bitcoinChain := newLocalBitcoinChain() + walletPublicKey := testWalletPublicKeyFromXOnly( + t, + "2336f65004d8f122f1fe947ebd009a8b4add3a0d937356d568e30f7fcc2e4008", + ) + walletMainUtxo := testTaprootWalletMainUtxo( + t, + bitcoinChain, + walletPublicKey, + ) + + var targetWalletID [32]byte + targetWalletID[31] = 1 + targetWalletOutputScript, err := bitcoin.PayToTaproot(targetWalletID) + if err != nil { + t.Fatal(err) + } + + builder, err := assembleMovingFundsTransaction( + bitcoinChain, + walletMainUtxo, + []bitcoin.Script{ + targetWalletOutputScript, + }, + 1000, + ) + if err != nil { + t.Fatal(err) + } + + if !builder.HasOnlyTaprootKeyPathInputs() { + t.Fatal("expected moving funds builder to use Taproot key-path inputs") + } +} + func TestValidateMovingFundsSafetyMargin(t *testing.T) { walletPublicKeyHash := hexToByte20( "ffb3f7538bfa98a511495dd96027cfbd57baf2fa", @@ -433,3 +480,22 @@ func hexToByte20(hexStr string) [20]byte { copy(result[:], decoded) return result } + +func testLegacyWalletOutputScripts( + walletPublicKeyHashes [][20]byte, +) ([]bitcoin.Script, error) { + outputScripts := make([]bitcoin.Script, len(walletPublicKeyHashes)) + + for i, walletPublicKeyHash := range walletPublicKeyHashes { + outputScript, err := bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) + if err != nil { + return nil, err + } + + outputScripts[i] = outputScript + } + + return outputScripts, nil +} diff --git a/pkg/tbtc/native_tbtc_signer_build_taproot_tx_default.go b/pkg/tbtc/native_tbtc_signer_build_taproot_tx_default.go new file mode 100644 index 0000000000..cf6334056e --- /dev/null +++ b/pkg/tbtc/native_tbtc_signer_build_taproot_tx_default.go @@ -0,0 +1,13 @@ +//go:build !(frost_native && frost_tbtc_signer && cgo) + +package tbtc + +import "github.com/keep-network/keep-core/pkg/bitcoin" + +// buildTaprootTxViaNativeSigner is a no-op on builds that do not link the +// native tbtc-signer bridge. +func buildTaprootTxViaNativeSigner( + unsignedTx *bitcoin.TransactionBuilder, +) (string, error) { + return "", nil +} diff --git a/pkg/tbtc/native_tbtc_signer_build_taproot_tx_frost_native_tbtc_signer.go b/pkg/tbtc/native_tbtc_signer_build_taproot_tx_frost_native_tbtc_signer.go new file mode 100644 index 0000000000..30658c0715 --- /dev/null +++ b/pkg/tbtc/native_tbtc_signer_build_taproot_tx_frost_native_tbtc_signer.go @@ -0,0 +1,108 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package tbtc + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/bitcoin" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func buildTaprootTxViaNativeSigner( + unsignedTx *bitcoin.TransactionBuilder, +) (string, error) { + if unsignedTx == nil { + return "", fmt.Errorf("unsigned transaction builder is nil") + } + + inputs, outputs, err := unsignedTx.UnsignedTransactionIO() + if err != nil { + return "", fmt.Errorf("cannot extract unsigned transaction I/O: [%w]", err) + } + + nativeInputs := make([]frostsigning.NativeTBTCSignerTxInput, 0, len(inputs)) + for _, input := range inputs { + nativeInputs = append( + nativeInputs, + frostsigning.NativeTBTCSignerTxInput{ + TxIDHex: input.TxIDHex, + Vout: input.Vout, + ValueSats: input.ValueSats, + }, + ) + } + + nativeOutputs := make([]frostsigning.NativeTBTCSignerTxOutput, 0, len(outputs)) + for _, output := range outputs { + nativeOutputs = append( + nativeOutputs, + frostsigning.NativeTBTCSignerTxOutput{ + ScriptPubKeyHex: output.ScriptPubKeyHex, + ValueSats: output.ValueSats, + }, + ) + } + + sessionID := buildTaprootTxSessionID(inputs, outputs) + + result, err := frostsigning.BuildNativeTBTCSignerTaprootTx( + sessionID, + nativeInputs, + nativeOutputs, + nil, + ) + if err != nil { + // Keep legacy fallback behavior for the observational BuildTaprootTx + // phase when native bridge support is unavailable. + if errors.Is(err, frostsigning.ErrNativeCryptographyUnavailable) { + return "", nil + } + + return "", err + } + + if result == nil { + return "", fmt.Errorf("native tbtc-signer returned nil BuildTaprootTx result") + } + + if result.SessionID != sessionID { + return "", fmt.Errorf( + "native tbtc-signer BuildTaprootTx returned unexpected session ID: [%v] != [%v]", + result.SessionID, + sessionID, + ) + } + + if result.TxHex == "" { + return "", fmt.Errorf("native tbtc-signer BuildTaprootTx returned empty tx hex") + } + + return result.TxHex, nil +} + +func buildTaprootTxSessionID( + inputs []bitcoin.UnsignedTransactionInput, + outputs []bitcoin.UnsignedTransactionOutput, +) string { + // Session ID is deterministically derived from Go-side transaction I/O using + // encoding/json. Rust currently treats this session_id as opaque. + // If input/output schema changes in a future migration phase, update this + // derivation intentionally to avoid silent cross-version session ID drift. + sessionPayload, err := json.Marshal(struct { + Inputs []bitcoin.UnsignedTransactionInput `json:"inputs"` + Outputs []bitcoin.UnsignedTransactionOutput `json:"outputs"` + }{ + Inputs: inputs, + Outputs: outputs, + }) + if err != nil { + return fmt.Sprintf("buildtx-fallback-%d-%d", len(inputs), len(outputs)) + } + + digest := sha256.Sum256(sessionPayload) + return fmt.Sprintf("buildtx-%x", digest[:]) +} diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index f8f40b9f7c..c93e0e4cf4 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -17,12 +17,12 @@ import ( "go.uber.org/zap" "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/announcer" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/protocol/inactivity" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" ) const ( @@ -136,6 +136,33 @@ func newNode( proposalGenerator CoordinationProposalGenerator, config Config, ) (*node, error) { + if err := RegisterSignerMaterialResolverForBuild(); err != nil { + return nil, fmt.Errorf( + "cannot register signer material resolver for build: %w", + err, + ) + } + + if err := configureFrostSigningBackend(config); err != nil { + return nil, fmt.Errorf("cannot configure FROST signing backend: %w", err) + } + + // Fail fast on an invalid FROST signing backend here, right after it is + // configured and before any further node construction - in particular before + // newDkgExecutor below starts the legacy ECDSA pre-params pool, which + // schedules CPU-heavy generation/persistence on a background context. This + // keeps the fail-closed path side-effect free. verifyFrostSigningBackend is a + // no-op unless FROST is enabled (a FROST wallet registry is configured); a + // FROST-enabled node on the legacy backend, or without a usable/linked native + // signer engine, cannot sign native FROST wallets. + if frostChain, ok := chain.(FrostDKGChain); ok { + if err := verifyFrostSigningBackend( + frostChain.FrostWalletRegistryAvailable(), + ); err != nil { + return nil, err + } + } + walletRegistry, err := newWalletRegistry( keyStorePersistance, chain.CalculateWalletID, @@ -177,25 +204,77 @@ func newNode( return nil, fmt.Errorf("cannot get node's operator address: [%v]", err) } - // TODO: This chicken and egg problem should be solved when - // waitForBlockHeight becomes a part of BlockHeightWaiter interface. - node.dkgExecutor = newDkgExecutor( - node.groupParameters, - node.operatorID, - operatorAddress, - chain, - netProvider, - walletRegistry, - latch, - config, - workPersistence, - scheduler, - node.waitForBlockHeight, - ) + if shouldRunLegacyECDSA(config) { + // TODO: This chicken and egg problem should be solved when + // waitForBlockHeight becomes a part of BlockHeightWaiter interface. + node.dkgExecutor = newDkgExecutor( + node.groupParameters, + node.operatorID, + operatorAddress, + chain, + netProvider, + walletRegistry, + latch, + config, + workPersistence, + scheduler, + node.waitForBlockHeight, + ) + } return node, nil } +func configureFrostSigningBackend(config Config) error { + return signing.SetExecutionBackendByName(config.FrostSigningBackend) +} + +// verifyFrostSigningBackend fails fast when FROST DKG is enabled while the +// configured signing backend is the transitional legacy backend. Native FROST +// wallets carry native signer material that the legacy backend cannot process, +// so a node left on the legacy backend produces valid wallets via DKG but then +// fails every signing attempt (heartbeat, deposit sweep, redemption, ...). The +// native backend handles both native FROST and legacy-ECDSA material, so it is +// always the correct choice once FROST is enabled. +// +// The guard is a no-op when FROST is not enabled (frostEnabled == false): the +// normal Ethereum TbtcChain satisfies FrostDKGChain even when no FROST wallet +// registry is configured, and in that case the node has no FROST wallets to +// sign for, so the default legacy backend is fine. +func verifyFrostSigningBackend(frostEnabled bool) error { + if !frostEnabled { + return nil + } + + backend := signing.CurrentExecutionBackendName() + if backend == signing.LegacyExecutionBackendName { + return fmt.Errorf( + "FROST DKG is enabled but the FROST signing backend is [%s]; set "+ + "tbtc.frostSigningBackend to \"native\" or \"ffi\" - the legacy "+ + "backend cannot sign native FROST wallets and would fail every "+ + "signature", + backend, + ) + } + + // A non-legacy backend name is not sufficient: the fallback-allowed "native" + // mode is selected without verifying that native execution is actually + // available, so an unavailable native engine would fall back to the legacy + // bridge and fail on native FROST signer material at signing time. Require + // usable native execution up front. + if !signing.NativeExecutionAvailable() { + return fmt.Errorf( + "FROST DKG is enabled with signing backend [%s] but native FROST "+ + "execution is unavailable in this build/runtime; use "+ + "tbtc.frostSigningBackend=\"ffi\" with the native tbtc-signer "+ + "linked in, otherwise signing falls back to the legacy bridge "+ + "and fails on native FROST wallets", + backend, + ) + } + return nil +} + // setPerformanceMetrics sets the performance metrics recorder for the node // and wires it into components that support metrics. func (n *node) setPerformanceMetrics(metrics interface { @@ -205,6 +284,25 @@ func (n *node) setPerformanceMetrics(metrics interface { }) { n.performanceMetrics = metrics + if metrics == nil { + signing.UnregisterNativeTBTCSignerFallbackObserver() + } else { + err := signing.RegisterNativeTBTCSignerFallbackObserver( + func(event signing.NativeTBTCSignerFallbackEvent) { + metrics.IncrementCounter( + clientinfo.MetricSigningNativeTBTCSignerFallbackTotal, + 1, + ) + }, + ) + if err != nil { + logger.Warnf( + "cannot register native tbtc-signer fallback observer: [%v]", + err, + ) + } + } + // Initialize window metrics tracker with performance metrics // Keep metrics for the last 100 windows (approximately 25 hours at 900 blocks per window) if perfMetrics, ok := metrics.(clientinfo.PerformanceMetricsRecorder); ok { @@ -295,6 +393,11 @@ func (n *node) joinDKGIfEligible( startBlock uint64, delayBlocks uint64, ) { + if n.dkgExecutor == nil { + logger.Warnf("legacy ECDSA DKG is disabled; ignoring DKG started event") + return + } + n.dkgExecutor.executeDkgIfEligible(seed, startBlock, delayBlocks) } @@ -309,6 +412,11 @@ func (n *node) validateDKG( result *DKGChainResult, resultHash [32]byte, ) { + if n.dkgExecutor == nil { + logger.Warnf("legacy ECDSA DKG is disabled; ignoring DKG result") + return + } + n.dkgExecutor.executeDkgValidation(seed, submissionBlock, result, resultHash) } @@ -1278,27 +1386,59 @@ func (n *node) archiveClosedWallets() error { for _, walletPublicKey := range walletPublicKeys { walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) - walletID, err := n.chain.CalculateWalletID(walletPublicKey) - if err != nil { - return fmt.Errorf( - "could not calculate wallet ID for wallet with public key "+ - "hash [0x%x]: [%v]", - walletPublicKeyHash, - err, - ) - } + var walletID [32]byte + var archiveWallet bool - isRegistered, err := n.chain.IsWalletRegistered(walletID) + walletChainData, err := n.chain.GetWallet(walletPublicKeyHash) if err != nil { - return fmt.Errorf( - "could not check if wallet is registered for wallet with ID "+ - "[0x%x]: [%v]", - walletPublicKeyHash, - err, - ) + walletID, err = n.chain.CalculateWalletID(walletPublicKey) + if err != nil { + return fmt.Errorf( + "could not resolve wallet IDs for wallet with public key "+ + "hash [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + // Legacy fallback for deployments where Bridge wallet state is + // unavailable. FROST wallets are registered in the Bridge but not + // in the legacy ECDSA wallet registry. + isRegistered, err := n.chain.IsWalletRegistered(walletID) + if err != nil { + return fmt.Errorf( + "could not check if wallet is registered for wallet with ECDSA ID "+ + "[0x%x]: [%v]", + walletID, + err, + ) + } + + if !isRegistered && n.frostWalletRegistryAvailable() { + logger.Infof( + "wallet with ECDSA ID [0x%x] and public key hash [0x%x] "+ + "was not found in Bridge or the legacy ECDSA registry; "+ + "preserving local key material because FROST wallet "+ + "registration is available and the wallet may be "+ + "pending Bridge registration", + walletID, + walletPublicKeyHash, + ) + continue + } + + archiveWallet = !isRegistered + } else { + walletID = walletChainData.WalletID + if walletID == [32]byte{} { + walletID = DeriveLegacyWalletID(walletPublicKeyHash) + } + + archiveWallet = walletChainData.State == StateClosed || + walletChainData.State == StateTerminated } - if !isRegistered { + if archiveWallet { // If the wallet is no longer registered it means the wallet has // been closed or terminated. err := n.walletRegistry.archiveWallet(walletPublicKeyHash) @@ -1322,6 +1462,16 @@ func (n *node) archiveClosedWallets() error { return nil } +type frostWalletRegistryAvailability interface { + FrostWalletRegistryAvailable() bool +} + +func (n *node) frostWalletRegistryAvailable() bool { + frostChain, ok := n.chain.(frostWalletRegistryAvailability) + + return ok && frostChain.FrostWalletRegistryAvailable() +} + // handleWalletClosure handles the wallet termination or closing process. func (n *node) handleWalletClosure(walletID [32]byte) error { blockCounter, err := n.chain.BlockCounter() @@ -1365,20 +1515,47 @@ func (n *node) handleWalletClosure(walletID [32]byte) error { return fmt.Errorf("wallet closure not confirmed") } - wallet, ok := n.walletRegistry.getWalletByID(walletID) + walletPublicKeyHash, err := n.chain.WalletPublicKeyHashForWalletID(walletID) + if err != nil { + // WalletClosed events still carry ECDSA wallet IDs from the legacy + // registry path. Until closure events are emitted with canonical IDs, + // canonical wallet-ID resolution is expected to miss and we use the + // local registry fallback below. + logger.Debugf( + "cannot resolve wallet public key hash for wallet ID [0x%x]: [%v]; "+ + "falling back to local wallet ID matching", + walletID, + err, + ) + + wallet, ok := n.walletRegistry.getWalletByID(walletID) + if !ok { + // Wallet was not found in the registry. The wallet is not controlled + // by this node. + logger.Infof( + "node does not control wallet with ID [0x%x]; quitting wallet "+ + "archiving", + walletID, + ) + return nil + } + + walletPublicKeyHash = bitcoin.PublicKeyHash(wallet.publicKey) + } + + _, ok := n.walletRegistry.getWalletByPublicKeyHash(walletPublicKeyHash) if !ok { // Wallet was not found in the registry. The wallet is not controlled by // this node. logger.Infof( - "node does not control wallet with ID [0x%x]; quitting wallet "+ - "archiving", + "node does not control wallet with ID [0x%x] and public key hash "+ + "[0x%x]; quitting wallet archiving", walletID, + walletPublicKeyHash, ) return nil } - walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) - err = n.walletRegistry.archiveWallet(walletPublicKeyHash) if err != nil { return fmt.Errorf("failed to archive the wallet: [%v]", err) @@ -1429,6 +1606,8 @@ func withCancelOnBlock( block uint64, waitForBlockFn waitForBlockFn, ) (context.Context, context.CancelFunc) { + // #nosec G118 -- The returned cancel function is intentionally propagated + // to the caller and also invoked by the helper goroutine below. blockCtx, cancelBlockCtx := context.WithCancel(ctx) go func() { diff --git a/pkg/tbtc/node_signing_backend_test.go b/pkg/tbtc/node_signing_backend_test.go new file mode 100644 index 0000000000..b652dad140 --- /dev/null +++ b/pkg/tbtc/node_signing_backend_test.go @@ -0,0 +1,136 @@ +package tbtc + +import ( + "context" + "errors" + "testing" + + "github.com/ipfs/go-log/v2" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/net" +) + +type noopNativeExecutionAdapter struct{} + +func (nnea *noopNativeExecutionAdapter) Execute( + ctx context.Context, + logger log.StandardLogger, + request *frostsigning.Request, +) (*frostsigning.Result, error) { + return nil, nil +} + +func (nnea *noopNativeExecutionAdapter) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} + +func TestConfigureFrostSigningBackend_Default(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := configureFrostSigningBackend(Config{}) + if err != nil { + t.Fatalf("unexpected config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.LegacyExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.LegacyExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func TestConfigureFrostSigningBackend_NativeUnavailable(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "native"}) + if err == nil { + t.Fatal("expected native backend config error") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } +} + +func TestConfigureFrostSigningBackend_FFIUnavailable(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err == nil { + t.Fatal("expected ffi backend config error") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } +} + +func TestConfigureFrostSigningBackend_NativeRegistered(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := frostsigning.RegisterNativeExecutionAdapter(&noopNativeExecutionAdapter{}) + if err != nil { + t.Fatalf("unexpected native adapter registration error: [%v]", err) + } + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "native"}) + if err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func TestConfigureFrostSigningBackend_FFIRegistered(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := frostsigning.RegisterNativeExecutionAdapter(&noopNativeExecutionAdapter{}) + if err != nil { + t.Fatalf("unexpected native adapter registration error: [%v]", err) + } + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected ffi backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} diff --git a/pkg/tbtc/node_startup_signing_backend_test.go b/pkg/tbtc/node_startup_signing_backend_test.go new file mode 100644 index 0000000000..4162814113 --- /dev/null +++ b/pkg/tbtc/node_startup_signing_backend_test.go @@ -0,0 +1,200 @@ +package tbtc + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/generator" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/net/local" +) + +func TestNewNode_ConfiguresFrostSigningBackend_NativeUnavailable(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + groupParameters, localChain, netProvider, keyStorePersistence := + setupNewNodeSigningBackendTestDependencies(t) + + _, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + netProvider, + keyStorePersistence, + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{FrostSigningBackend: "native"}, + ) + if err == nil { + t.Fatal("expected newNode startup error for unavailable native backend") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected newNode startup error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } +} + +func TestNewNode_ConfiguresFrostSigningBackend_FFIUnavailable(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + groupParameters, localChain, netProvider, keyStorePersistence := + setupNewNodeSigningBackendTestDependencies(t) + + _, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + netProvider, + keyStorePersistence, + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{FrostSigningBackend: "ffi"}, + ) + if err == nil { + t.Fatal("expected newNode startup error for unavailable ffi backend") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected newNode startup error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } +} + +func TestNewNode_ConfiguresFrostSigningBackend_NativeRegistered(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := frostsigning.RegisterNativeExecutionAdapter(&noopNativeExecutionAdapter{}) + if err != nil { + t.Fatalf("unexpected native adapter registration error: [%v]", err) + } + + groupParameters, localChain, netProvider, keyStorePersistence := + setupNewNodeSigningBackendTestDependencies(t) + + node, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + netProvider, + keyStorePersistence, + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{FrostSigningBackend: "native"}, + ) + if err != nil { + t.Fatalf("unexpected newNode startup error: [%v]", err) + } + + if node == nil { + t.Fatal("expected node instance") + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func TestNewNode_ConfiguresFrostSigningBackend_FFIRegistered(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := frostsigning.RegisterNativeExecutionAdapter(&noopNativeExecutionAdapter{}) + if err != nil { + t.Fatalf("unexpected native adapter registration error: [%v]", err) + } + + groupParameters, localChain, netProvider, keyStorePersistence := + setupNewNodeSigningBackendTestDependencies(t) + + node, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + netProvider, + keyStorePersistence, + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{FrostSigningBackend: "ffi"}, + ) + if err != nil { + t.Fatalf("unexpected newNode startup error: [%v]", err) + } + + if node == nil { + t.Fatal("expected node instance") + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func setupNewNodeSigningBackendTestDependencies( + t *testing.T, +) ( + *GroupParameters, + Chain, + net.Provider, + *mockPersistenceHandle, +) { + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + localChain := Connect() + netProvider := local.Connect() + signer := createMockSigner(t) + + walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + walletID, err := localChain.CalculateWalletID(signer.wallet.publicKey) + if err != nil { + t.Fatal(err) + } + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: walletID, + State: StateLive, + }, + ) + + return groupParameters, + localChain, + netProvider, + createMockKeyStorePersistence(t, signer) +} diff --git a/pkg/tbtc/node_test.go b/pkg/tbtc/node_test.go index a756c69595..69232fe9be 100644 --- a/pkg/tbtc/node_test.go +++ b/pkg/tbtc/node_test.go @@ -101,9 +101,7 @@ func TestNode_GetSigningExecutor(t *testing.T) { len(executor.signers), ) - if !reflect.DeepEqual(signer, executor.signers[0]) { - t.Errorf("executor holds an unexpected signer") - } + assertSignerEquivalent(t, "executor signer", signer, executor.signers[0]) expectedChannel := fmt.Sprintf( "%s-%s", @@ -288,6 +286,138 @@ func TestNode_GetCoordinationExecutor(t *testing.T) { } } +func TestNode_KeepsLiveBridgeWalletWithoutLegacyRegistration(t *testing.T) { + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + localChain := Connect() + localProvider := local.Connect() + + signer := createMockSigner(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + WalletID: [32]byte{31: 0x01}, + State: StateLive, + }, + ) + + n, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + localProvider, + createMockKeyStorePersistence(t, signer), + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{}, + ) + if err != nil { + t.Fatal(err) + } + + _, ok := n.walletRegistry.getWalletByPublicKeyHash(walletPublicKeyHash) + if !ok { + t.Fatal("live Bridge wallet should not be archived") + } +} + +func TestNode_KeepsPendingFrostWalletWithoutBridgeRegistration(t *testing.T) { + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + localChain := Connect() + localChain.frostWalletRegistryAvailable = true + localProvider := local.Connect() + + signer := createMockSigner(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + + n, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + localProvider, + createMockKeyStorePersistence(t, signer), + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{}, + ) + if err != nil { + t.Fatal(err) + } + + _, ok := n.walletRegistry.getWalletByPublicKeyHash(walletPublicKeyHash) + if !ok { + t.Fatal("pending FROST wallet should not be archived") + } +} + +func TestNode_ArchivesClosedBridgeWallet(t *testing.T) { + testCases := map[string]WalletState{ + "closed": StateClosed, + "terminated": StateTerminated, + } + + for name, walletState := range testCases { + t.Run(name, func(t *testing.T) { + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + localChain := Connect() + localProvider := local.Connect() + + signer := createMockSigner(t) + walletPublicKeyHash := bitcoin.PublicKeyHash( + signer.wallet.publicKey, + ) + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + WalletID: [32]byte{31: 0x01}, + State: walletState, + }, + ) + + n, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + localProvider, + createMockKeyStorePersistence(t, signer), + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{}, + ) + if err != nil { + t.Fatal(err) + } + + _, ok := n.walletRegistry.getWalletByPublicKeyHash( + walletPublicKeyHash, + ) + if ok { + t.Fatal("closed Bridge wallet should be archived") + } + }) + } +} + func TestNode_RunCoordinationLayer(t *testing.T) { groupParameters := &GroupParameters{ GroupSize: 5, @@ -1328,6 +1458,7 @@ func createMockSigner(t *testing.T) *signer { }, signingGroupMemberIndex: group.MemberIndex(1), privateKeyShare: privateKeyShare, + signerMaterial: privateKeyShare, } } diff --git a/pkg/tbtc/redemption.go b/pkg/tbtc/redemption.go index 1dd950c95f..53064f887a 100644 --- a/pkg/tbtc/redemption.go +++ b/pkg/tbtc/redemption.go @@ -191,8 +191,8 @@ func (ra *redemptionAction) execute() error { return fmt.Errorf("validate proposal step failed: [%v]", err) } - walletMainUtxo, err := DetermineWalletMainUtxo( - walletPublicKeyHash, + walletMainUtxo, err := DetermineWalletMainUtxoForPublicKey( + ra.wallet().publicKey, ra.chain, ra.btcChain, ) @@ -215,8 +215,8 @@ func (ra *redemptionAction) execute() error { return fmt.Errorf("redeeming wallet has no main UTXO") } - err = EnsureWalletSyncedBetweenChains( - walletPublicKeyHash, + err = EnsureWalletSyncedBetweenChainsForPublicKey( + ra.wallet().publicKey, walletMainUtxo, ra.chain, ra.btcChain, @@ -508,14 +508,31 @@ func assembleRedemptionTransaction( // If we can have a non-zero change, construct it. if changeOutputValue > 0 { - changeOutputScript, err := bitcoin.PayToWitnessPublicKeyHash( - bitcoin.PublicKeyHash(walletPublicKey), - ) - if err != nil { - return nil, fmt.Errorf( - "cannot compute change output script: [%v]", - err, + var changeOutputScript bitcoin.Script + var err error + if builder.HasOnlyTaprootKeyPathInputs() { + walletXOnlyPublicKey, err := walletXOnlyPublicKey(walletPublicKey) + if err != nil { + return nil, err + } + + changeOutputScript, err = bitcoin.PayToTaproot(walletXOnlyPublicKey) + if err != nil { + return nil, fmt.Errorf( + "cannot compute Taproot change output script: [%v]", + err, + ) + } + } else { + changeOutputScript, err = bitcoin.PayToWitnessPublicKeyHash( + bitcoin.PublicKeyHash(walletPublicKey), ) + if err != nil { + return nil, fmt.Errorf( + "cannot compute change output script: [%v]", + err, + ) + } } changeOutput := &bitcoin.TransactionOutput{ diff --git a/pkg/tbtc/redemption_test.go b/pkg/tbtc/redemption_test.go index 0a6897dd94..b2c35b8cb9 100644 --- a/pkg/tbtc/redemption_test.go +++ b/pkg/tbtc/redemption_test.go @@ -6,12 +6,11 @@ import ( "testing" "time" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/go-test/deep" "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/tbtc/internal/test" ) @@ -104,14 +103,13 @@ func TestRedemptionAction_Execute(t *testing.T) { // Create a signing executor mock instance. signingExecutor := newMockWalletSigningExecutor() - // The signature within the scenario fixture is in the format - // suitable for applying them directly to a Bitcoin transaction. - // However, the signing executor operates on raw tECDSA signatures - // so, we need to unpack it first. - rawSignature := &tecdsa.Signature{ - R: scenario.Signature.R, - S: scenario.Signature.S, - } + // The signature within the scenario fixture is represented as + // big integer components and needs conversion to runtime signature + // container used by signing executor. + rawSignature := mustFrostSignatureFromBigInts( + scenario.Signature.R, + scenario.Signature.S, + ) // Set up the signing executor mock to return the signature from // the test fixture when called with the expected parameters. @@ -120,7 +118,7 @@ func TestRedemptionAction_Execute(t *testing.T) { signingExecutor.setSignatures( []*big.Int{scenario.ExpectedSigHash}, proposalProcessingStartBlock, - []*tecdsa.Signature{rawSignature}, + []*frost.Signature{rawSignature}, ) action := newRedemptionAction( diff --git a/pkg/tbtc/registry.go b/pkg/tbtc/registry.go index d39b56849d..69a29e36fc 100644 --- a/pkg/tbtc/registry.go +++ b/pkg/tbtc/registry.go @@ -67,7 +67,10 @@ func newWalletRegistry( // them. wallet := signers[0].wallet walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) - walletID, err := calculateWalletIdFunc(wallet.publicKey) + walletID, err := calculateWalletIDForSigner( + signers[0], + calculateWalletIdFunc, + ) if err != nil { return nil, fmt.Errorf( "error while calculating wallet ID for wallet with public "+ @@ -134,7 +137,10 @@ func (wr *walletRegistry) registerSigner(signer *signer) error { // the hashes are computed only once. No need to initialize signers slice as // appending works with nil values. if _, ok := wr.walletCache[walletStorageKey]; !ok { - walletID, err := wr.calculateWalletIdFunc(signer.wallet.publicKey) + walletID, err := calculateWalletIDForSigner( + signer, + wr.calculateWalletIdFunc, + ) if err != nil { return fmt.Errorf("cannot calculate wallet ID: [%v]", err) } diff --git a/pkg/tbtc/registry_test.go b/pkg/tbtc/registry_test.go index f0d4964ce1..ae5a7ed589 100644 --- a/pkg/tbtc/registry_test.go +++ b/pkg/tbtc/registry_test.go @@ -283,9 +283,12 @@ func TestWalletRegistry_PrePopulateWalletCache(t *testing.T) { len(walletRegistry.walletCache[walletStorageKey].signers), ) - if !reflect.DeepEqual(signer, walletRegistry.walletCache[walletStorageKey].signers[0]) { - t.Errorf("loaded wallet signer differs from the original one") - } + assertSignerEquivalent( + t, + "pre-populated wallet signer", + signer, + walletRegistry.walletCache[walletStorageKey].signers[0], + ) } func TestWalletRegistry_GetWalletsPublicKeys(t *testing.T) { @@ -459,9 +462,12 @@ func TestWalletStorage_LoadSigners(t *testing.T) { len(signersByWallet[walletStorageKey]), ) - if !reflect.DeepEqual(signer, signersByWallet[walletStorageKey][0]) { - t.Errorf("loaded wallet signer differs from the original one") - } + assertSignerEquivalent( + t, + "loaded wallet signer", + signer, + signersByWallet[walletStorageKey][0], + ) } func TestWalletStorage_ArchiveWallet(t *testing.T) { diff --git a/pkg/tbtc/signature_test_helpers_test.go b/pkg/tbtc/signature_test_helpers_test.go new file mode 100644 index 0000000000..b4019893d0 --- /dev/null +++ b/pkg/tbtc/signature_test_helpers_test.go @@ -0,0 +1,23 @@ +package tbtc + +import ( + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/frost" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func mustFrostSignatureFromBigInts(r *big.Int, s *big.Int) *frost.Signature { + return mustFrostSignatureFromTECDSA(&tecdsa.Signature{R: r, S: s}) +} + +func mustFrostSignatureFromTECDSA(signature *tecdsa.Signature) *frost.Signature { + result, err := frostsigning.FromTECDSASignature(signature) + if err != nil { + panic(fmt.Sprintf("signature conversion failed: %v", err)) + } + + return result +} diff --git a/pkg/tbtc/signer_equivalence_test.go b/pkg/tbtc/signer_equivalence_test.go new file mode 100644 index 0000000000..382ba85bd2 --- /dev/null +++ b/pkg/tbtc/signer_equivalence_test.go @@ -0,0 +1,82 @@ +package tbtc + +import ( + "bytes" + "reflect" + "testing" +) + +func assertSignerEquivalent( + t *testing.T, + name string, + expected *signer, + actual *signer, +) { + t.Helper() + + if expected == nil { + if actual != nil { + t.Fatalf("%s should be nil", name) + } + return + } + + if actual == nil { + t.Fatalf("%s is nil", name) + } + + if !expected.wallet.publicKey.Equal(actual.wallet.publicKey) { + t.Fatalf("%s has unexpected wallet public key", name) + } + + if !reflect.DeepEqual( + expected.wallet.signingGroupOperators, + actual.wallet.signingGroupOperators, + ) { + t.Fatalf( + "%s has unexpected signing group operators\nexpected: [%v]\nactual: [%v]", + name, + expected.wallet.signingGroupOperators, + actual.wallet.signingGroupOperators, + ) + } + + if expected.signingGroupMemberIndex != actual.signingGroupMemberIndex { + t.Fatalf( + "%s has unexpected member index\nexpected: [%v]\nactual: [%v]", + name, + expected.signingGroupMemberIndex, + actual.signingGroupMemberIndex, + ) + } + + if expected.privateKeyShare == nil { + if actual.privateKeyShare != nil { + t.Fatalf("%s should have nil private key share", name) + } + return + } + + if actual.privateKeyShare == nil { + t.Fatalf("%s has nil private key share", name) + } + + expectedPrivateKeyShare, err := expected.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("cannot marshal expected private key share for %s: [%v]", name, err) + } + + actualPrivateKeyShare, err := actual.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("cannot marshal actual private key share for %s: [%v]", name, err) + } + + if !bytes.Equal(expectedPrivateKeyShare, actualPrivateKeyShare) { + t.Fatalf( + "%s has unexpected private key share\nexpected: [%x]\nactual: [%x]", + name, + expectedPrivateKeyShare, + actualPrivateKeyShare, + ) + } +} diff --git a/pkg/tbtc/signer_material_encoding.go b/pkg/tbtc/signer_material_encoding.go new file mode 100644 index 0000000000..dba6e6e7f4 --- /dev/null +++ b/pkg/tbtc/signer_material_encoding.go @@ -0,0 +1,300 @@ +package tbtc + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +var signerMaterialEnvelopePrefix = []byte("tbtc-signer-material-v1:") + +// signerMaterialMaxFormatLength bounds the length of the format identifier in +// a serialized signer-material envelope. Real format identifiers are short +// labels like "frost-tbtc-signer-v1", so 256 bytes is generous; the cap exists +// to refuse a uvarint-claimed length that would allocate a huge string from a +// hostile or corrupted payload before the existing `offset+int(formatLength) > +// len(data)` bounds check runs. +const signerMaterialMaxFormatLength uint64 = 256 + +// signerMaterialMaxPayloadLength bounds the length of the payload body. JSON +// envelopes for FROST and the tBTC-signer key material carry tens of KiB of +// hex; 256 KiB is comfortably above that and refuses a uvarint-claimed length +// that would allocate hundreds of MiB from a corrupted state file or a +// hostile peer. +const signerMaterialMaxPayloadLength uint64 = 256 * 1024 + +type unmarshaledSignerMaterial struct { + signerMaterial any + privateKeyShare *tecdsa.PrivateKeyShare +} + +func marshalSignerMaterialForPersistence( + signerMaterial any, + fallbackPrivateKeyShare *tecdsa.PrivateKeyShare, +) ([]byte, error) { + if signerMaterial == nil { + signerMaterial = fallbackPrivateKeyShare + } + + switch material := signerMaterial.(type) { + case *tecdsa.PrivateKeyShare: + if material == nil { + return nil, fmt.Errorf("legacy private key share is nil") + } + + return material.Marshal() + case tecdsa.PrivateKeyShare: + materialCopy := material + return (&materialCopy).Marshal() + case *frostsigning.NativeSignerMaterial: + if material == nil { + return nil, fmt.Errorf("native signer material is nil") + } + + return encodeNativeSignerMaterialForPersistence( + material.Format, + material.Payload, + ) + case frostsigning.NativeSignerMaterial: + return encodeNativeSignerMaterialForPersistence( + material.Format, + material.Payload, + ) + case []byte: + // Transitional compatibility: raw bytes are treated as legacy + // frost-uniffi-v1 payloads from previously persisted signer entries. + return encodeNativeSignerMaterialForPersistence( + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + material, + ) + default: + return nil, fmt.Errorf("unsupported signer material type: [%T]", signerMaterial) + } +} + +func unmarshalSignerMaterialFromPersistence( + data []byte, +) (*unmarshaledSignerMaterial, error) { + nativeSignerMaterial, isNative, err := decodeNativeSignerMaterialFromPersistence( + data, + ) + if err != nil { + return nil, err + } + + if isNative { + privateKeyShare := legacyPrivateKeyShareFromNativeSignerMaterial( + nativeSignerMaterial, + ) + + return &unmarshaledSignerMaterial{ + signerMaterial: nativeSignerMaterial, + privateKeyShare: privateKeyShare, + }, nil + } + + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(data); err != nil { + return nil, fmt.Errorf("cannot unmarshal private key share: [%w]", err) + } + + resolvedSignerMaterial, err := resolveSignerMaterial(privateKeyShare) + if err != nil { + return nil, fmt.Errorf( + "cannot resolve signer material from legacy private key share: [%w]", + err, + ) + } + + if resolvedSignerMaterial == nil { + return nil, fmt.Errorf( + "resolved signer material from legacy private key share is nil", + ) + } + + return &unmarshaledSignerMaterial{ + signerMaterial: resolvedSignerMaterial, + privateKeyShare: privateKeyShare, + }, nil +} + +func encodeNativeSignerMaterialForPersistence( + format string, + payload []byte, +) ([]byte, error) { + material := &frostsigning.NativeSignerMaterial{ + Format: format, + Payload: append([]byte{}, payload...), + } + + if err := validateNativeSignerMaterialForPersistence(material); err != nil { + return nil, err + } + + result := make([]byte, 0, len(signerMaterialEnvelopePrefix)+len(format)+len(payload)+20) + result = append(result, signerMaterialEnvelopePrefix...) + + var varintBuffer [binary.MaxVarintLen64]byte + + formatLength := binary.PutUvarint(varintBuffer[:], uint64(len(material.Format))) + result = append(result, varintBuffer[:formatLength]...) + result = append(result, []byte(material.Format)...) + + payloadLength := binary.PutUvarint(varintBuffer[:], uint64(len(material.Payload))) + result = append(result, varintBuffer[:payloadLength]...) + result = append(result, material.Payload...) + + return result, nil +} + +func decodeNativeSignerMaterialFromPersistence( + data []byte, +) ( + *frostsigning.NativeSignerMaterial, + bool, + error, +) { + if !bytes.HasPrefix(data, signerMaterialEnvelopePrefix) { + return nil, false, nil + } + + offset := len(signerMaterialEnvelopePrefix) + + formatLength, lengthBytes, err := readPersistenceUvarint(data, offset) + if err != nil { + return nil, true, fmt.Errorf("invalid signer material format length: [%w]", err) + } + if formatLength > signerMaterialMaxFormatLength { + return nil, true, fmt.Errorf( + "signer material format length %d exceeds maximum %d", + formatLength, + signerMaterialMaxFormatLength, + ) + } + offset += lengthBytes + + if offset+int(formatLength) > len(data) { + return nil, true, fmt.Errorf("signer material format length exceeds payload") + } + + format := string(data[offset : offset+int(formatLength)]) + offset += int(formatLength) + + payloadLength, lengthBytes, err := readPersistenceUvarint(data, offset) + if err != nil { + return nil, true, fmt.Errorf("invalid signer material payload length: [%w]", err) + } + if payloadLength > signerMaterialMaxPayloadLength { + return nil, true, fmt.Errorf( + "signer material payload length %d exceeds maximum %d", + payloadLength, + signerMaterialMaxPayloadLength, + ) + } + offset += lengthBytes + + if offset+int(payloadLength) > len(data) { + return nil, true, fmt.Errorf("signer material payload length exceeds payload") + } + + payload := append([]byte{}, data[offset:offset+int(payloadLength)]...) + offset += int(payloadLength) + + if offset != len(data) { + return nil, true, fmt.Errorf("unexpected trailing signer material payload bytes") + } + + material := &frostsigning.NativeSignerMaterial{ + Format: format, + Payload: payload, + } + + if err := validateNativeSignerMaterialForPersistence(material); err != nil { + return nil, true, err + } + + return material, true, nil +} + +func validateNativeSignerMaterialForPersistence( + material *frostsigning.NativeSignerMaterial, +) error { + if material == nil { + return fmt.Errorf("native signer material is nil") + } + + if material.Format == "" { + return fmt.Errorf("native signer material format is empty") + } + + if len(material.Payload) == 0 { + return fmt.Errorf("native signer material payload is empty") + } + + return nil +} + +func readPersistenceUvarint(data []byte, offset int) (uint64, int, error) { + if offset >= len(data) { + return 0, 0, fmt.Errorf("offset [%d] out of bounds", offset) + } + + value, lengthBytes := binary.Uvarint(data[offset:]) + if lengthBytes == 0 { + return 0, 0, fmt.Errorf("incomplete uvarint") + } + + if lengthBytes < 0 { + return 0, 0, fmt.Errorf("overflowed uvarint") + } + + return value, lengthBytes, nil +} + +func legacyPrivateKeyShareFromNativeSignerMaterial( + nativeSignerMaterial *frostsigning.NativeSignerMaterial, +) *tecdsa.PrivateKeyShare { + if nativeSignerMaterial == nil { + return nil + } + + switch nativeSignerMaterial.Format { + case frostsigning.NativeSignerMaterialFormatFrostUniFFIV1: + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(nativeSignerMaterial.Payload); err != nil { + return nil + } + + return privateKeyShare + + case frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1: + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(nativeSignerMaterial.Payload, &payload); err != nil { + return nil + } + + if payload.LegacyPrivateKeyShareHex == "" { + return nil + } + + legacyPayload, err := hex.DecodeString(payload.LegacyPrivateKeyShareHex) + if err != nil { + return nil + } + + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(legacyPayload); err != nil { + return nil + } + + return privateKeyShare + + default: + return nil + } +} diff --git a/pkg/tbtc/signer_material_encoding_default_build_test.go b/pkg/tbtc/signer_material_encoding_default_build_test.go new file mode 100644 index 0000000000..031d28477e --- /dev/null +++ b/pkg/tbtc/signer_material_encoding_default_build_test.go @@ -0,0 +1,52 @@ +//go:build !frost_native + +package tbtc + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestUnmarshalSignerMaterialFromPersistence_LegacyEncoding_DefaultBuildReturnsLegacySignerMaterial( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + if err := RegisterSignerMaterialResolverForBuild(); err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + legacyEncoded, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling legacy private key share: [%v]", err) + } + + unmarshaledSignerMaterial, err := unmarshalSignerMaterialFromPersistence( + legacyEncoded, + ) + if err != nil { + t.Fatalf("unexpected unmarshal error: [%v]", err) + } + + if unmarshaledSignerMaterial.privateKeyShare == nil { + t.Fatal("expected private key share") + } + + resolvedPrivateKeyShare, ok := unmarshaledSignerMaterial.signerMaterial.(*tecdsa.PrivateKeyShare) + if !ok { + t.Fatalf( + "unexpected signer material type\nexpected: [%T]\nactual: [%T]", + &tecdsa.PrivateKeyShare{}, + unmarshaledSignerMaterial.signerMaterial, + ) + } + + if resolvedPrivateKeyShare != unmarshaledSignerMaterial.privateKeyShare { + t.Fatal("expected signer material to reference recovered private key share") + } +} diff --git a/pkg/tbtc/signer_material_encoding_frost_native_test.go b/pkg/tbtc/signer_material_encoding_frost_native_test.go new file mode 100644 index 0000000000..4602473724 --- /dev/null +++ b/pkg/tbtc/signer_material_encoding_frost_native_test.go @@ -0,0 +1,155 @@ +//go:build frost_native && !(frost_tbtc_signer && cgo) + +package tbtc + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" + "github.com/keep-network/keep-core/pkg/tecdsa" + "google.golang.org/protobuf/proto" +) + +func TestUnmarshalSignerMaterialFromPersistence_LegacyEncodingResolvesNativeMaterialOnFrostNativeBuild( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + if err := RegisterSignerMaterialResolverForBuild(); err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + legacyEncoded, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling legacy private key share: [%v]", err) + } + + unmarshaledSignerMaterial, err := unmarshalSignerMaterialFromPersistence( + legacyEncoded, + ) + if err != nil { + t.Fatalf("unexpected unmarshal error: [%v]", err) + } + + if unmarshaledSignerMaterial.privateKeyShare == nil { + t.Fatal("expected legacy private key share to be preserved") + } + + nativeSignerMaterial, ok := unmarshaledSignerMaterial.signerMaterial.(*frostsigning.NativeSignerMaterial) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + unmarshaledSignerMaterial.signerMaterial, + ) + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + nativeSignerMaterial.Format, + ) + } + + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(nativeSignerMaterial.Payload, &payload); err != nil { + t.Fatalf("failed unmarshalling tbtc signer material payload: [%v]", err) + } + + if payload.KeyGroup == "" { + t.Fatal("expected non-empty tbtc-signer key group") + } + + if payload.KeyGroupSource == "" { + t.Fatal("expected non-empty tbtc-signer key group source") + } + + legacyPrivateKeySharePayload, err := hex.DecodeString(payload.LegacyPrivateKeyShareHex) + if err != nil { + t.Fatalf("failed decoding legacy private key share payload: [%v]", err) + } + + decodedPrivateKeyShare := &tecdsa.PrivateKeyShare{} + if err := decodedPrivateKeyShare.Unmarshal(legacyPrivateKeySharePayload); err != nil { + t.Fatalf("failed unmarshalling decoded private key share: [%v]", err) + } + + actualPayload, err := decodedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling decoded private key share: [%v]", err) + } + + if !bytes.Equal(actualPayload, legacyEncoded) { + t.Fatalf( + "unexpected resolved signer payload\nexpected: [%x]\nactual: [%x]", + legacyEncoded, + actualPayload, + ) + } +} + +func TestSignerMarshalling_LegacyRoundtripMigratesToNativeEnvelopeOnFrostNativeBuild( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + if err := RegisterSignerMaterialResolverForBuild(); err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + legacySigner := createMockSigner(t) + legacySigner.signerMaterial = legacySigner.privateKeyShare + + initialEncodedSigner, err := legacySigner.Marshal() + if err != nil { + t.Fatalf("unexpected initial signer marshal error: [%v]", err) + } + + initialPBSigner := &pb.Signer{} + if err := proto.Unmarshal(initialEncodedSigner, initialPBSigner); err != nil { + t.Fatalf("unexpected initial proto unmarshal error: [%v]", err) + } + + if bytes.HasPrefix(initialPBSigner.PrivateKeyShare, signerMaterialEnvelopePrefix) { + t.Fatal("expected initial legacy signer encoding without native envelope") + } + + unmarshaledSigner := &signer{} + if err := unmarshaledSigner.Unmarshal(initialEncodedSigner); err != nil { + t.Fatalf("unexpected signer unmarshal error: [%v]", err) + } + + if _, ok := unmarshaledSigner.signerMaterial.(*frostsigning.NativeSignerMaterial); !ok { + t.Fatalf( + "unexpected signer material type after legacy unmarshal\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + unmarshaledSigner.signerMaterial, + ) + } + + migratedEncodedSigner, err := unmarshaledSigner.Marshal() + if err != nil { + t.Fatalf("unexpected migrated signer marshal error: [%v]", err) + } + + migratedPBSigner := &pb.Signer{} + if err := proto.Unmarshal(migratedEncodedSigner, migratedPBSigner); err != nil { + t.Fatalf("unexpected migrated proto unmarshal error: [%v]", err) + } + + if !bytes.HasPrefix(migratedPBSigner.PrivateKeyShare, signerMaterialEnvelopePrefix) { + t.Fatal("expected migrated signer encoding with native envelope prefix") + } +} diff --git a/pkg/tbtc/signer_material_encoding_test.go b/pkg/tbtc/signer_material_encoding_test.go new file mode 100644 index 0000000000..fdef4ccb34 --- /dev/null +++ b/pkg/tbtc/signer_material_encoding_test.go @@ -0,0 +1,335 @@ +package tbtc + +import ( + "bytes" + "encoding/binary" + "reflect" + "strings" + "testing" + + "github.com/google/gofuzz" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/internal/pbutils" + "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" + "github.com/keep-network/keep-core/pkg/tecdsa" + "google.golang.org/protobuf/proto" +) + +// appendUvarintForTest emits a uvarint matching the format +// `unmarshalSignerMaterialFromPersistence` expects. It is duplicated in the +// test package rather than exported so test-only construction of corrupted +// envelopes cannot accidentally be reused by production code. +func appendUvarintForTest(buf []byte, value uint64) []byte { + var scratch [binary.MaxVarintLen64]byte + n := binary.PutUvarint(scratch[:], value) + return append(buf, scratch[:n]...) +} + +func TestMarshalSignerMaterialForPersistence_LegacyPrivateKeyShare(t *testing.T) { + signer := createMockSigner(t) + + encoded, err := marshalSignerMaterialForPersistence( + signer.privateKeyShare, + nil, + ) + if err != nil { + t.Fatalf("unexpected marshal error: [%v]", err) + } + + _, isNative, err := decodeNativeSignerMaterialFromPersistence(encoded) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if isNative { + t.Fatal("expected legacy private key share encoding") + } + + decoded := &tecdsa.PrivateKeyShare{} + if err := decoded.Unmarshal(encoded); err != nil { + t.Fatalf("unexpected legacy unmarshal error: [%v]", err) + } +} + +func TestMarshalSignerMaterialForPersistence_NativeSignerMaterial(t *testing.T) { + payload := []byte{0xaa, 0xbb, 0xcc} + encoded, err := marshalSignerMaterialForPersistence( + &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + Payload: payload, + }, + nil, + ) + if err != nil { + t.Fatalf("unexpected marshal error: [%v]", err) + } + + decoded, isNative, err := decodeNativeSignerMaterialFromPersistence(encoded) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if !isNative { + t.Fatal("expected native signer material envelope") + } + + if decoded == nil { + t.Fatal("expected native signer material") + } + + if decoded.Format != frostsigning.NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected decoded format\nexpected: [%v]\nactual: [%v]", + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + decoded.Format, + ) + } + + if !bytes.Equal(decoded.Payload, payload) { + t.Fatalf( + "unexpected decoded payload\nexpected: [%x]\nactual: [%x]", + payload, + decoded.Payload, + ) + } +} + +func TestUnmarshalSignerMaterialFromPersistence_NativeEnvelope(t *testing.T) { + signer := createMockSigner(t) + payload, err := signer.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("unexpected private key share marshal error: [%v]", err) + } + + encoded, err := encodeNativeSignerMaterialForPersistence( + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + payload, + ) + if err != nil { + t.Fatalf("unexpected encode error: [%v]", err) + } + + decoded, err := unmarshalSignerMaterialFromPersistence(encoded) + if err != nil { + t.Fatalf("unexpected unmarshal error: [%v]", err) + } + + if decoded.privateKeyShare == nil { + t.Fatal("expected legacy private key share recovery from native signer material") + } + + recoveredPayload, err := decoded.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("unexpected recovered private key share marshal error: [%v]", err) + } + + if !bytes.Equal(recoveredPayload, payload) { + t.Fatalf( + "unexpected recovered private key share\nexpected: [%x]\nactual: [%x]", + payload, + recoveredPayload, + ) + } + + nativeSignerMaterial, ok := decoded.signerMaterial.(*frostsigning.NativeSignerMaterial) + if !ok { + t.Fatalf( + "unexpected signer material type\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + decoded.signerMaterial, + ) + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + nativeSignerMaterial.Format, + ) + } + + if !bytes.Equal(nativeSignerMaterial.Payload, payload) { + t.Fatalf( + "unexpected signer material payload\nexpected: [%x]\nactual: [%x]", + payload, + nativeSignerMaterial.Payload, + ) + } +} + +func TestUnmarshalSignerMaterialFromPersistence_CorruptedNativeEnvelope(t *testing.T) { + encoded, err := encodeNativeSignerMaterialForPersistence( + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + []byte{0x10, 0x20}, + ) + if err != nil { + t.Fatalf("unexpected encode error: [%v]", err) + } + + encoded = encoded[:len(encoded)-1] + + _, err = unmarshalSignerMaterialFromPersistence(encoded) + if err == nil { + t.Fatal("expected unmarshal error") + } + + if !strings.Contains(err.Error(), "signer material payload length exceeds payload") { + t.Fatalf( + "unexpected unmarshal error\nexpected substring: [%s]\nactual: [%v]", + "signer material payload length exceeds payload", + err, + ) + } +} + +func TestUnmarshalSignerMaterialFromPersistence_RejectsOversizedFormatLength( + t *testing.T, +) { + // Build an envelope that claims a format length one byte above the cap. + // The body itself is short, so without the length cap the bounds check + // would still catch this, but the cap rejects the claim earlier and with + // a clear error before any allocation. + encoded := append([]byte{}, signerMaterialEnvelopePrefix...) + encoded = appendUvarintForTest(encoded, signerMaterialMaxFormatLength+1) + encoded = append(encoded, []byte("ignored")...) + + _, err := unmarshalSignerMaterialFromPersistence(encoded) + if err == nil { + t.Fatal("expected unmarshal error") + } + + if !strings.Contains(err.Error(), "format length") || + !strings.Contains(err.Error(), "exceeds maximum") { + t.Fatalf( + "unexpected unmarshal error\nexpected substrings: [format length], [exceeds maximum]\nactual: [%v]", + err, + ) + } +} + +func TestUnmarshalSignerMaterialFromPersistence_RejectsOversizedPayloadLength( + t *testing.T, +) { + encoded := append([]byte{}, signerMaterialEnvelopePrefix...) + format := []byte(frostsigning.NativeSignerMaterialFormatFrostUniFFIV1) + encoded = appendUvarintForTest(encoded, uint64(len(format))) + encoded = append(encoded, format...) + encoded = appendUvarintForTest(encoded, signerMaterialMaxPayloadLength+1) + + _, err := unmarshalSignerMaterialFromPersistence(encoded) + if err == nil { + t.Fatal("expected unmarshal error") + } + + if !strings.Contains(err.Error(), "payload length") || + !strings.Contains(err.Error(), "exceeds maximum") { + t.Fatalf( + "unexpected unmarshal error\nexpected substrings: [payload length], [exceeds maximum]\nactual: [%v]", + err, + ) + } +} + +func TestMarshalSignerMaterialForPersistence_UnsupportedType(t *testing.T) { + _, err := marshalSignerMaterialForPersistence(struct{}{}, nil) + if err == nil { + t.Fatal("expected marshal error") + } + + if !strings.Contains(err.Error(), "unsupported signer material type") { + t.Fatalf( + "unexpected marshal error\nexpected substring: [%s]\nactual: [%v]", + "unsupported signer material type", + err, + ) + } +} + +func TestSignerMarshalling_NativeSignerMaterialRoundtrip(t *testing.T) { + legacySigner := createMockSigner(t) + marshaled := &signer{ + wallet: legacySigner.wallet, + signingGroupMemberIndex: legacySigner.signingGroupMemberIndex, + signerMaterial: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0x44, 0x55, 0x66}, + }, + } + unmarshaled := &signer{} + + if err := pbutils.RoundTrip(marshaled, unmarshaled); err != nil { + t.Fatal(err) + } + + if unmarshaled.privateKeyShare != nil { + t.Fatal("expected nil private key share for native signer material") + } + + if !reflect.DeepEqual(marshaled.wallet, unmarshaled.wallet) { + t.Fatalf( + "unexpected wallet state after roundtrip\nexpected: [%+v]\nactual: [%+v]", + marshaled.wallet, + unmarshaled.wallet, + ) + } + + if marshaled.signingGroupMemberIndex != unmarshaled.signingGroupMemberIndex { + t.Fatalf( + "unexpected signer member index\nexpected: [%v]\nactual: [%v]", + marshaled.signingGroupMemberIndex, + unmarshaled.signingGroupMemberIndex, + ) + } + + nativeSignerMaterial, ok := unmarshaled.signerMaterial.(*frostsigning.NativeSignerMaterial) + if !ok { + t.Fatalf( + "unexpected signer material type\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + unmarshaled.signerMaterial, + ) + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + nativeSignerMaterial.Format, + ) + } + + if !bytes.Equal(nativeSignerMaterial.Payload, []byte{0x44, 0x55, 0x66}) { + t.Fatalf( + "unexpected signer material payload\nexpected: [%x]\nactual: [%x]", + []byte{0x44, 0x55, 0x66}, + nativeSignerMaterial.Payload, + ) + } +} + +func TestSignerMarshalling_LegacyEncodingDoesNotUseNativeEnvelope(t *testing.T) { + signer := createMockSigner(t) + + encodedSigner, err := signer.Marshal() + if err != nil { + t.Fatalf("unexpected marshal error: [%v]", err) + } + + pbSigner := &pb.Signer{} + if err := proto.Unmarshal(encodedSigner, pbSigner); err != nil { + t.Fatalf("unexpected proto unmarshal error: [%v]", err) + } + + if bytes.HasPrefix(pbSigner.PrivateKeyShare, signerMaterialEnvelopePrefix) { + t.Fatal("expected legacy signer encoding without native envelope") + } +} + +func TestFuzzDecodeNativeSignerMaterialFromPersistence(t *testing.T) { + for i := 0; i < 10; i++ { + var data []byte + fuzz.New().NilChance(0.1).NumElements(0, 256).Fuzz(&data) + + _, _, _ = decodeNativeSignerMaterialFromPersistence(data) + } +} diff --git a/pkg/tbtc/signer_material_resolver.go b/pkg/tbtc/signer_material_resolver.go new file mode 100644 index 0000000000..ce9dc08d06 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver.go @@ -0,0 +1,109 @@ +package tbtc + +import ( + "fmt" + "sync" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +// SignerMaterialResolver derives signer material from a legacy private key +// share. Implementations can provide backend-native signer material while +// preserving fallback compatibility. +type SignerMaterialResolver interface { + ResolveSignerMaterial(privateKeyShare *tecdsa.PrivateKeyShare) (any, error) +} + +// SignerMaterialResolverProviderForBuild produces a signer material resolver +// bound to the current build/runtime flavor. +type SignerMaterialResolverProviderForBuild func() (SignerMaterialResolver, error) + +type legacyPrivateKeyShareSignerMaterialResolver struct{} + +func (lpkssmr *legacyPrivateKeyShareSignerMaterialResolver) ResolveSignerMaterial( + privateKeyShare *tecdsa.PrivateKeyShare, +) (any, error) { + if privateKeyShare == nil { + return nil, fmt.Errorf("private key share is nil") + } + + return privateKeyShare, nil +} + +var ( + signerMaterialResolverMutex sync.RWMutex + signerMaterialResolver SignerMaterialResolver = &legacyPrivateKeyShareSignerMaterialResolver{} + signerMaterialResolverProviderForBuild SignerMaterialResolverProviderForBuild +) + +// RegisterSignerMaterialResolver registers a signer material resolver used by +// DKG signer construction. +func RegisterSignerMaterialResolver(resolver SignerMaterialResolver) error { + if resolver == nil { + return fmt.Errorf("signer material resolver is nil") + } + + signerMaterialResolverMutex.Lock() + defer signerMaterialResolverMutex.Unlock() + + signerMaterialResolver = resolver + + return nil +} + +// UnregisterSignerMaterialResolver restores the default legacy resolver. +func UnregisterSignerMaterialResolver() { + signerMaterialResolverMutex.Lock() + defer signerMaterialResolverMutex.Unlock() + + signerMaterialResolver = &legacyPrivateKeyShareSignerMaterialResolver{} +} + +// RegisterSignerMaterialResolverProviderForBuild registers a provider used by +// RegisterSignerMaterialResolverForBuild. +func RegisterSignerMaterialResolverProviderForBuild( + provider SignerMaterialResolverProviderForBuild, +) error { + if provider == nil { + return fmt.Errorf("signer material resolver provider is nil") + } + + signerMaterialResolverMutex.Lock() + defer signerMaterialResolverMutex.Unlock() + + signerMaterialResolverProviderForBuild = provider + + return nil +} + +// UnregisterSignerMaterialResolverProviderForBuild clears build-scoped resolver +// provider registration. +func UnregisterSignerMaterialResolverProviderForBuild() { + signerMaterialResolverMutex.Lock() + defer signerMaterialResolverMutex.Unlock() + + signerMaterialResolverProviderForBuild = nil +} + +func currentSignerMaterialResolver() SignerMaterialResolver { + signerMaterialResolverMutex.RLock() + defer signerMaterialResolverMutex.RUnlock() + + return signerMaterialResolver +} + +func currentSignerMaterialResolverProviderForBuild() SignerMaterialResolverProviderForBuild { + signerMaterialResolverMutex.RLock() + defer signerMaterialResolverMutex.RUnlock() + + return signerMaterialResolverProviderForBuild +} + +func resolveSignerMaterial(privateKeyShare *tecdsa.PrivateKeyShare) (any, error) { + resolver := currentSignerMaterialResolver() + if resolver == nil { + return nil, fmt.Errorf("signer material resolver is nil") + } + + return resolver.ResolveSignerMaterial(privateKeyShare) +} diff --git a/pkg/tbtc/signer_material_resolver_build.go b/pkg/tbtc/signer_material_resolver_build.go new file mode 100644 index 0000000000..115bd05b9d --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build.go @@ -0,0 +1,7 @@ +package tbtc + +// RegisterSignerMaterialResolverForBuild attempts to register signer-material +// resolver bindings for the current build flavor. +func RegisterSignerMaterialResolverForBuild() error { + return registerSignerMaterialResolverForBuild() +} diff --git a/pkg/tbtc/signer_material_resolver_build_default.go b/pkg/tbtc/signer_material_resolver_build_default.go new file mode 100644 index 0000000000..a1d8cd7a23 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build_default.go @@ -0,0 +1,7 @@ +//go:build !frost_native + +package tbtc + +func registerSignerMaterialResolverForBuild() error { + return nil +} diff --git a/pkg/tbtc/signer_material_resolver_build_frost_native.go b/pkg/tbtc/signer_material_resolver_build_frost_native.go new file mode 100644 index 0000000000..3cef396081 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build_frost_native.go @@ -0,0 +1,74 @@ +//go:build frost_native && !(frost_tbtc_signer && cgo) + +package tbtc + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func registerSignerMaterialResolverForBuild() error { + provider := currentSignerMaterialResolverProviderForBuild() + if provider == nil { + provider = defaultSignerMaterialResolverProviderForBuild + } + + resolver, err := provider() + if err != nil { + return err + } + + if resolver == nil { + return fmt.Errorf("signer material resolver is nil") + } + + return RegisterSignerMaterialResolver(resolver) +} + +func defaultSignerMaterialResolverProviderForBuild() (SignerMaterialResolver, error) { + return &buildTaggedNativeSignerMaterialResolver{}, nil +} + +// buildTaggedNativeSignerMaterialResolver derives transitional native signer +// material from a legacy private key share for frost_native builds not using +// the `frost_tbtc_signer` tag. +type buildTaggedNativeSignerMaterialResolver struct{} + +func (btnsmr *buildTaggedNativeSignerMaterialResolver) ResolveSignerMaterial( + privateKeyShare *tecdsa.PrivateKeyShare, +) (any, error) { + if privateKeyShare == nil { + return nil, fmt.Errorf("private key share is nil") + } + + legacyPrivateKeySharePayload, err := privateKeyShare.Marshal() + if err != nil { + return nil, fmt.Errorf("cannot marshal private key share: [%w]", err) + } + + walletPublicKeyBytes, err := marshalPublicKey(privateKeyShare.PublicKey()) + if err != nil { + return nil, fmt.Errorf("cannot marshal wallet public key: [%w]", err) + } + + keyGroupDigest := sha256.Sum256(walletPublicKeyBytes) + + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: hex.EncodeToString(keyGroupDigest[:]), + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: hex.EncodeToString(legacyPrivateKeySharePayload), + }) + if err != nil { + return nil, fmt.Errorf("cannot marshal tbtc signer material payload: [%w]", err) + } + + return &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, nil +} diff --git a/pkg/tbtc/signer_material_resolver_build_frost_native_tbtc_signer.go b/pkg/tbtc/signer_material_resolver_build_frost_native_tbtc_signer.go new file mode 100644 index 0000000000..268b53a521 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build_frost_native_tbtc_signer.go @@ -0,0 +1,91 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package tbtc + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func registerSignerMaterialResolverForBuild() error { + provider := currentSignerMaterialResolverProviderForBuild() + if provider == nil { + provider = defaultSignerMaterialResolverProviderForBuild + } + + resolver, err := provider() + if err != nil { + return err + } + + if resolver == nil { + return fmt.Errorf("signer material resolver is nil") + } + + return RegisterSignerMaterialResolver(resolver) +} + +func defaultSignerMaterialResolverProviderForBuild() (SignerMaterialResolver, error) { + return &buildTaggedNativeSignerMaterialResolver{}, nil +} + +// buildTaggedNativeSignerMaterialResolver derives transitional signer material +// for frost_tbtc_signer builds. It carries a deterministic key-group handle and +// embeds legacy private-key-share bytes to preserve temporary Go-side fallback. +type buildTaggedNativeSignerMaterialResolver struct{} + +func (btnsmr *buildTaggedNativeSignerMaterialResolver) ResolveSignerMaterial( + privateKeyShare *tecdsa.PrivateKeyShare, +) (any, error) { + if privateKeyShare == nil { + return nil, fmt.Errorf("private key share is nil") + } + + legacyPrivateKeySharePayload, err := privateKeyShare.Marshal() + if err != nil { + return nil, fmt.Errorf("cannot marshal private key share: [%w]", err) + } + + walletPublicKeyBytes, err := marshalPublicKey(privateKeyShare.PublicKey()) + if err != nil { + return nil, fmt.Errorf("cannot marshal wallet public key: [%w]", err) + } + + keyGroupDigest := sha256.Sum256(walletPublicKeyBytes) + + // Scaffold-era key-group derivation: the current value identifies + // placeholder material derived from the legacy wallet public-key hash, + // not the output of a real FROST DKG run. Refuse to surface that material + // at all unless the operator has explicitly opted in via + // AcceptScaffoldKeyGroupEnvVar — production deployments must never set + // this. See native_tbtc_signer_material.go for the env-var contract. + if !frostsigning.AcceptScaffoldKeyGroupEnabled() { + return nil, fmt.Errorf( + "refusing to build scaffold-era %q signer material; set %s=true to "+ + "opt in for local/CI use only, never in production", + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + frostsigning.AcceptScaffoldKeyGroupEnvVar, + ) + } + + // TODO: Replace this placeholder key-group derivation with Rust DKG output. + // The current value identifies scaffold-era material only. + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: hex.EncodeToString(keyGroupDigest[:]), + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: hex.EncodeToString(legacyPrivateKeySharePayload), + }) + if err != nil { + return nil, fmt.Errorf("cannot marshal tbtc signer material payload: [%w]", err) + } + + return &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, nil +} diff --git a/pkg/tbtc/signer_material_resolver_build_frost_native_tbtc_signer_test.go b/pkg/tbtc/signer_material_resolver_build_frost_native_tbtc_signer_test.go new file mode 100644 index 0000000000..24e5fa44c5 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build_frost_native_tbtc_signer_test.go @@ -0,0 +1,64 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package tbtc + +import ( + "strings" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +// TestRegisterSignerMaterialResolverForBuild_DefaultProviderRefusesScaffoldWithoutOptIn +// asserts the cgo frost_tbtc_signer resolver REFUSES to surface scaffold-era +// signer material unless the operator opts in via AcceptScaffoldKeyGroupEnvVar. +// +// This is cgo-only behaviour: only the frost_tbtc_signer (cgo) resolver carries +// the resolver-level refusal guard. The non-cgo frost_native resolver +// intentionally PERMITS scaffold resolution -- it is the transitional build, and +// the deeper native_ffi_primitive guard refuses scaffold material at signing +// time instead. The migration tests in signer_material_encoding_frost_native_test.go +// (tagged for the non-cgo build) assert that permissive behaviour. This test +// therefore lives behind the cgo tag so it exercises the resolver whose contract +// it describes; previously it was tagged plain frost_native and failed under any +// non-cgo frost_native build. +func TestRegisterSignerMaterialResolverForBuild_DefaultProviderRefusesScaffoldWithoutOptIn( + t *testing.T, +) { + // Force the env var to "" so a stray external value cannot suppress the + // scaffold refusal during this regression test. + t.Setenv(frostsigning.AcceptScaffoldKeyGroupEnvVar, "") + + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + err := RegisterSignerMaterialResolverForBuild() + if err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + _, err = resolveSignerMaterial(privateKeyShare) + if err == nil { + t.Fatal( + "expected scaffold-refusal error from default resolver without opt-in", + ) + } + + if !strings.Contains(err.Error(), frostsigning.AcceptScaffoldKeyGroupEnvVar) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + frostsigning.AcceptScaffoldKeyGroupEnvVar, + err, + ) + } + if !strings.Contains(err.Error(), frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + err, + ) + } +} diff --git a/pkg/tbtc/signer_material_resolver_build_frost_native_test.go b/pkg/tbtc/signer_material_resolver_build_frost_native_test.go new file mode 100644 index 0000000000..ae472686fb --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build_frost_native_test.go @@ -0,0 +1,200 @@ +//go:build frost_native + +package tbtc + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestRegisterSignerMaterialResolverForBuild_UsesDefaultProvider( + t *testing.T, +) { + // Default scaffold-era resolver builds legacy-wallet-pubkey signer + // material; production refuses it but local/CI tests can opt in. + t.Setenv(frostsigning.AcceptScaffoldKeyGroupEnvVar, "true") + + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + err := RegisterSignerMaterialResolverForBuild() + if err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + + result, err := resolveSignerMaterial(privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + nativeSignerMaterial, ok := result.(*frostsigning.NativeSignerMaterial) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + result, + ) + } + + expectedPayload, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling expected private key share: [%v]", err) + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1 { + t.Fatalf( + "unexpected native signer material format\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + nativeSignerMaterial.Format, + ) + } + + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(nativeSignerMaterial.Payload, &payload); err != nil { + t.Fatalf("failed unmarshalling tbtc signer material payload: [%v]", err) + } + + if payload.KeyGroup == "" { + t.Fatal("expected non-empty tbtc-signer key group") + } + + if payload.KeyGroupSource == "" { + t.Fatal("expected non-empty tbtc-signer key group source") + } + + legacyPrivateKeySharePayload, err := hex.DecodeString(payload.LegacyPrivateKeyShareHex) + if err != nil { + t.Fatalf("failed decoding legacy private key share payload: [%v]", err) + } + + decodedPrivateKeyShare := &tecdsa.PrivateKeyShare{} + if err := decodedPrivateKeyShare.Unmarshal(legacyPrivateKeySharePayload); err != nil { + t.Fatalf("failed unmarshalling decoded private key share: [%v]", err) + } + + actualPayload, err := decodedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling decoded private key share: [%v]", err) + } + + if !bytes.Equal(expectedPayload, actualPayload) { + t.Fatalf( + "unexpected resolved signer payload\nexpected: [%x]\nactual: [%x]", + expectedPayload, + actualPayload, + ) + } +} + +func TestRegisterSignerMaterialResolverForBuild_UsesRegisteredProvider( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + expected := []byte{0xaa, 0xbb} + err := RegisterSignerMaterialResolverProviderForBuild( + func() (SignerMaterialResolver, error) { + return &staticSignerMaterialResolver{ + result: expected, + }, nil + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + err = RegisterSignerMaterialResolverForBuild() + if err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + result, err := resolveSignerMaterial(createMockSigner(t).privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + resultBytes, ok := result.([]byte) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + []byte{}, + result, + ) + } + + if len(resultBytes) != len(expected) || + resultBytes[0] != expected[0] || + resultBytes[1] != expected[1] { + t.Fatalf( + "unexpected resolved signer material\nexpected: [%x]\nactual: [%x]", + expected, + resultBytes, + ) + } +} + +func TestRegisterSignerMaterialResolverForBuild_ProviderError(t *testing.T) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + expectedErr := errors.New("provider error") + err := RegisterSignerMaterialResolverProviderForBuild( + func() (SignerMaterialResolver, error) { + return nil, expectedErr + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + err = RegisterSignerMaterialResolverForBuild() + if err == nil { + t.Fatal("expected build resolver registration error") + } + + if !errors.Is(err, expectedErr) { + t.Fatalf( + "unexpected build resolver registration error\nexpected: [%v]\nactual: [%v]", + expectedErr, + err, + ) + } +} + +func TestRegisterSignerMaterialResolverForBuild_ProviderReturnsNilResolver( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + err := RegisterSignerMaterialResolverProviderForBuild( + func() (SignerMaterialResolver, error) { + return nil, nil + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + err = RegisterSignerMaterialResolverForBuild() + if err == nil { + t.Fatal("expected build resolver registration error") + } +} diff --git a/pkg/tbtc/signer_material_resolver_default_build_test.go b/pkg/tbtc/signer_material_resolver_default_build_test.go new file mode 100644 index 0000000000..c25489b72e --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_default_build_test.go @@ -0,0 +1,44 @@ +//go:build !frost_native + +package tbtc + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestRegisterSignerMaterialResolverForBuild_DefaultBuildNoop(t *testing.T) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + err := RegisterSignerMaterialResolverForBuild() + if err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + result, err := resolveSignerMaterial(privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + resolvedPrivateKeyShare, ok := result.(*tecdsa.PrivateKeyShare) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + &tecdsa.PrivateKeyShare{}, + result, + ) + } + + if resolvedPrivateKeyShare != privateKeyShare { + t.Fatalf( + "unexpected resolved private key share\nexpected: [%v]\nactual: [%v]", + privateKeyShare, + resolvedPrivateKeyShare, + ) + } +} diff --git a/pkg/tbtc/signer_material_resolver_test.go b/pkg/tbtc/signer_material_resolver_test.go new file mode 100644 index 0000000000..52ef802800 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_test.go @@ -0,0 +1,129 @@ +package tbtc + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +type staticSignerMaterialResolver struct { + result any + err error +} + +func (ssmr *staticSignerMaterialResolver) ResolveSignerMaterial( + privateKeyShare *tecdsa.PrivateKeyShare, +) (any, error) { + return ssmr.result, ssmr.err +} + +func TestRegisterSignerMaterialResolver_Nil(t *testing.T) { + err := RegisterSignerMaterialResolver(nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRegisterSignerMaterialResolverProviderForBuild_Nil(t *testing.T) { + err := RegisterSignerMaterialResolverProviderForBuild(nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestResolveSignerMaterial_DefaultResolver(t *testing.T) { + UnregisterSignerMaterialResolver() + t.Cleanup(UnregisterSignerMaterialResolver) + + privateKeyShare := createMockSigner(t).privateKeyShare + + result, err := resolveSignerMaterial(privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + resolvedPrivateKeyShare, ok := result.(*tecdsa.PrivateKeyShare) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + &tecdsa.PrivateKeyShare{}, + result, + ) + } + + if resolvedPrivateKeyShare != privateKeyShare { + t.Fatalf( + "unexpected resolved private key share\nexpected: [%v]\nactual: [%v]", + privateKeyShare, + resolvedPrivateKeyShare, + ) + } +} + +func TestResolveSignerMaterial_RegisteredResolver(t *testing.T) { + UnregisterSignerMaterialResolver() + t.Cleanup(UnregisterSignerMaterialResolver) + + expected := []byte{0xaa, 0xbb} + err := RegisterSignerMaterialResolver( + &staticSignerMaterialResolver{ + result: expected, + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + result, err := resolveSignerMaterial(createMockSigner(t).privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + resultBytes, ok := result.([]byte) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + []byte{}, + result, + ) + } + + if len(resultBytes) != len(expected) || + resultBytes[0] != expected[0] || + resultBytes[1] != expected[1] { + t.Fatalf( + "unexpected resolved signer material\nexpected: [%x]\nactual: [%x]", + expected, + resultBytes, + ) + } +} + +func TestResolveSignerMaterial_ResolverError(t *testing.T) { + UnregisterSignerMaterialResolver() + t.Cleanup(UnregisterSignerMaterialResolver) + + expectedErr := errors.New("resolver error") + err := RegisterSignerMaterialResolver( + &staticSignerMaterialResolver{ + err: expectedErr, + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + _, err = resolveSignerMaterial(createMockSigner(t).privateKeyShare) + if err == nil { + t.Fatal("expected resolver error") + } + + if !errors.Is(err, expectedErr) { + t.Fatalf( + "unexpected resolver error\nexpected: [%v]\nactual: [%v]", + expectedErr, + err, + ) + } +} diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 346b6b0446..1c5e9437a2 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -2,6 +2,8 @@ package tbtc import ( "context" + "crypto/sha256" + "encoding/binary" "fmt" "math/big" "strings" @@ -9,12 +11,13 @@ import ( "time" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/announcer" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" "go.uber.org/zap" "golang.org/x/sync/semaphore" ) @@ -40,6 +43,64 @@ const ( // cannot execute the requested signature due to an ongoing signing. var errSigningExecutorBusy = fmt.Errorf("signing executor is busy") +func signingSessionID( + message *big.Int, + taprootMerkleRoot *[32]byte, + startBlock uint64, + attemptNumber uint, +) string { + if taprootMerkleRoot == nil { + return fmt.Sprintf("%v-%v", message.Text(16), attemptNumber) + } + + var startBlockBytes [8]byte + binary.BigEndian.PutUint64(startBlockBytes[:], startBlock) + + sessionDigest := sha256.New() + sessionDigest.Write([]byte(message.Text(16))) + sessionDigest.Write([]byte{0}) + sessionDigest.Write(taprootMerkleRoot[:]) + sessionDigest.Write([]byte{0}) + sessionDigest.Write(startBlockBytes[:]) + + return fmt.Sprintf("tr-%x-%v", sessionDigest.Sum(nil), attemptNumber) +} + +// roastSessionID is the STABLE per-signing ROAST session id: the signing +// session key WITHOUT the attempt number, so it is constant across every +// attempt of one message-signing. RFC-21 Phase 7.3 ROAST orchestration, the +// transition-record registry, and the participant selector key off it so +// attempt N+1's selector can find attempt N's transition record; the +// coarse/legacy path keeps using the attempt-specific signingSessionID for its +// replay isolation. The "roast-" prefix keeps this id disjoint from any +// signingSessionID value. +func roastSessionID( + message *big.Int, + taprootMerkleRoot *[32]byte, + startBlock uint64, +) string { + if taprootMerkleRoot == nil { + // startBlock is included so two independent signings of the SAME message + // at different start blocks get distinct stable ids (and so distinct + // transition-record / interactive-engine namespaces); without it a later + // signing could collide with retained ROAST state from an earlier one. + // The taproot branch below already binds startBlock. + return fmt.Sprintf("roast-%v-%v", message.Text(16), startBlock) + } + + var startBlockBytes [8]byte + binary.BigEndian.PutUint64(startBlockBytes[:], startBlock) + + sessionDigest := sha256.New() + sessionDigest.Write([]byte(message.Text(16))) + sessionDigest.Write([]byte{0}) + sessionDigest.Write(taprootMerkleRoot[:]) + sessionDigest.Write([]byte{0}) + sessionDigest.Write(startBlockBytes[:]) + + return fmt.Sprintf("roast-tr-%x", sessionDigest.Sum(nil)) +} + // signingExecutor is a component responsible for executing signing related to // a specific wallet whose part is controlled by this node. type signingExecutor struct { @@ -67,8 +128,21 @@ type signingExecutor struct { SetGauge(name string, value float64) RecordDuration(name string, duration time.Duration) } + + // livenessTracker is optional and feeds the RFC-21 Annex B implied-f + // signing-attempt liveness gauges. It is shared across all wallets' + // executors of this node (acquired from the metrics recorder). + livenessTracker *clientinfo.SigningAttemptLivenessTracker +} + +// signingLivenessTrackerProvider is implemented by metrics recorders that +// own the process-wide signing-attempt liveness tracker. +type signingLivenessTrackerProvider interface { + SigningAttemptLivenessTracker() *clientinfo.SigningAttemptLivenessTracker } +var _ schnorrWalletSigningExecutor = (*signingExecutor)(nil) + func newSigningExecutor( signers []*signer, broadcastChannel net.BroadcastChannel, @@ -92,6 +166,16 @@ func newSigningExecutor( } } +func (se *signingExecutor) usesSchnorrSignatures() bool { + for _, signer := range se.signers { + if signingMaterialUsesSchnorrSignatures(signer.signingMaterial()) { + return true + } + } + + return false +} + // signBatch performs the signing process for each message from the given // messages batch, one after another. If at least one message cannot be signed, // this function returns an error. If all messages were signed successfully, @@ -102,7 +186,24 @@ func (se *signingExecutor) signBatch( ctx context.Context, messages []*big.Int, startBlock uint64, -) ([]*tecdsa.Signature, error) { +) ([]*frost.Signature, error) { + return se.signBatchWithTaprootMerkleRoots(ctx, messages, nil, startBlock) +} + +func (se *signingExecutor) signBatchWithTaprootMerkleRoots( + ctx context.Context, + messages []*big.Int, + taprootMerkleRoots []*[32]byte, + startBlock uint64, +) ([]*frost.Signature, error) { + if taprootMerkleRoots != nil && len(taprootMerkleRoots) != len(messages) { + return nil, fmt.Errorf( + "taproot merkle roots count [%v] does not match messages count [%v]", + len(taprootMerkleRoots), + len(messages), + ) + } + wallet := se.wallet() walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) @@ -139,7 +240,7 @@ func (se *signingExecutor) signBatch( ) signingStartBlock := startBlock // start block for the first signing - signatures := make([]*tecdsa.Signature, len(messages)) + signatures := make([]*frost.Signature, len(messages)) endBlocks := make([]uint64, len(messages)) for i, message := range messages { @@ -154,7 +255,17 @@ func (se *signingExecutor) signBatch( signingStartBlock = endBlocks[i-1] + signingBatchInterludeBlocks } - signature, _, endBlock, err := se.sign(ctx, message, signingStartBlock) + var taprootMerkleRoot *[32]byte + if taprootMerkleRoots != nil { + taprootMerkleRoot = taprootMerkleRoots[i] + } + + signature, _, endBlock, err := se.signWithTaprootMerkleRoot( + ctx, + message, + taprootMerkleRoot, + signingStartBlock, + ) if err != nil { // Error metrics are recorded in the sign() method for all error paths. return nil, err @@ -184,7 +295,16 @@ func (se *signingExecutor) sign( ctx context.Context, message *big.Int, startBlock uint64, -) (*tecdsa.Signature, *signingActivityReport, uint64, error) { +) (*frost.Signature, *signingActivityReport, uint64, error) { + return se.signWithTaprootMerkleRoot(ctx, message, nil, startBlock) +} + +func (se *signingExecutor) signWithTaprootMerkleRoot( + ctx context.Context, + message *big.Int, + taprootMerkleRoot *[32]byte, + startBlock uint64, +) (*frost.Signature, *signingActivityReport, uint64, error) { if lockAcquired := se.lock.TryAcquire(1); !lockAcquired { // Record failure metrics for lock acquisition failure if se.metricsRecorder != nil { @@ -223,7 +343,7 @@ func (se *signingExecutor) sign( ) type signingOutcome struct { - signature *tecdsa.Signature + signature *frost.Signature activityReport *signingActivityReport endBlock uint64 } @@ -232,6 +352,12 @@ func (se *signingExecutor) sign( wg.Add(len(se.signers)) signingOutcomeChan := make(chan *signingOutcome, len(se.signers)) + // roastSID is the STABLE ROAST session id (no attempt number) shared by every + // signer's retry loop and signing request, so the ROAST participant selector + // and transition-record registry are keyed consistently across this signing's + // attempts. Computed once; constant across signers and attempts. + roastSID := roastSessionID(message, taprootMerkleRoot, startBlock) + for _, currentSigner := range se.signers { go func(signer *signer) { se.protocolLatch.Lock() @@ -247,6 +373,7 @@ func (se *signingExecutor) sign( doneCheck := newSigningDoneCheck( se.groupParameters.GroupSize, + se.groupParameters.HonestThreshold, se.broadcastChannel, se.membershipValidator, ) @@ -254,6 +381,7 @@ func (se *signingExecutor) sign( retryLoop := newSigningRetryLoop( signingLogger, message, + roastSID, startBlock, signer.signingGroupMemberIndex, wallet.signingGroupOperators, @@ -262,6 +390,16 @@ func (se *signingExecutor) sign( doneCheck, ) + // Every local signer of this wallet observes the same + // network-wide attempts, so exactly one of them reports + // outcomes to the liveness tracker to avoid multiplying + // observations of a single attempt. + if signer == se.signers[0] && se.livenessTracker != nil { + retryLoop.setAttemptOutcomeReporter( + se.livenessTracker.RecordAttemptOutcome, + ) + } + // Set up the loop timeout signal. This context is associated with // all attempts and gets canceled in three situations: // - one of the attempts failed with an error, @@ -280,6 +418,32 @@ func (se *signingExecutor) sign( se.waitForBlockFn, ) + // RFC-21 Phase 7.3 PR2b-1b: install the per-signer ROAST transition + // controller, scoped to loopCtx (the session lifetime). It observes + // every attempt so this seat can verify the attempt's transition bundle + // and run NextAttempt for participant selection. nil in builds/ + // deployments without ROAST retry, in which case the loop skips all + // transition steps. The request template carries the static signing + // material; the controller stamps each attempt's metadata itself. + retryLoop.setTransitionController(newRoastTransitionController( + loopCtx, + signingLogger, + &signing.Request{ + Message: message, + RoastSessionID: roastSID, + MemberIndex: signer.signingGroupMemberIndex, + SignerMaterial: signer.signingMaterial(), + TaprootMerkleRoot: taprootMerkleRoot, + GroupSize: wallet.groupSize(), + DishonestThreshold: wallet.groupDishonestThreshold( + se.groupParameters.HonestThreshold, + ), + Channel: se.broadcastChannel, + MembershipValidator: se.membershipValidator, + }, + se.waitForBlockFn, + )) + loopResult, err := retryLoop.start( loopCtx, se.waitForBlockFn, @@ -291,12 +455,47 @@ func (se *signingExecutor) sign( zap.Uint64("attemptTimeoutBlock", attempt.timeoutBlock), ) + includedMembersIndexes := attemptIncludedMembersIndexes( + wallet.groupSize(), + attempt.excludedMembersIndexes, + ) + + coordinatorMemberIndex, err := roast.SelectCoordinator( + includedMembersIndexes, + signingAttemptSeed(message), + attempt.number, + ) + if err != nil { + return nil, 0, fmt.Errorf( + "cannot select signing coordinator for attempt [%v]: [%w]", + attempt.number, + err, + ) + } + + // RFC-21 Phase 7.3 PR2b-1b: attempt.number is the committed roast + // attempt number under active ROAST retry (set by the loop), so the + // coordinator election, session id, and this AttemptContext all key + // off the committed identity -- matching the observe/transition + // context. TransientlyParkedMembersIndexes is carried through so the + // active context's parking is byte-identical to the observe context's + // (BuildAttemptContextFromRequest splits Excluded into permanent + + // parked from it). + attemptInfo := &signing.Attempt{ + Number: attempt.number, + CoordinatorMemberIndex: coordinatorMemberIndex, + IncludedMembersIndexes: includedMembersIndexes, + ExcludedMembersIndexes: attempt.excludedMembersIndexes, + TransientlyParkedMembersIndexes: attempt.transientlyParkedMembersIndexes, + } + signingAttemptLogger.Infof( "[member:%v] starting signing protocol "+ - "with [%v] group members (excluded: [%v])", + "with [%v] group members (coordinator: [%v], excluded: [%v])", signer.signingGroupMemberIndex, - wallet.groupSize()-len(attempt.excludedMembersIndexes), - attempt.excludedMembersIndexes, + len(includedMembersIndexes), + coordinatorMemberIndex, + attemptInfo.ExcludedMembersIndexes, ) // Set up the attempt timeout signal. @@ -313,26 +512,32 @@ func (se *signingExecutor) sign( se.waitForBlockFn, ) - sessionID := fmt.Sprintf( - "%v-%v", - message.Text(16), + sessionID := signingSessionID( + message, + taprootMerkleRoot, + startBlock, attempt.number, ) - result, err := signing.Execute( + result, err := signing.ExecuteRequest( attemptCtx, signingAttemptLogger, - message, - sessionID, - signer.signingGroupMemberIndex, - signer.privateKeyShare, - wallet.groupSize(), - wallet.groupDishonestThreshold( - se.groupParameters.HonestThreshold, - ), - attempt.excludedMembersIndexes, - se.broadcastChannel, - se.membershipValidator, + &signing.Request{ + Message: message, + SessionID: sessionID, + RoastSessionID: roastSID, + MemberIndex: signer.signingGroupMemberIndex, + SignerMaterial: signer.signingMaterial(), + PrivateKeyShare: signer.privateKeyShare, + TaprootMerkleRoot: taprootMerkleRoot, + GroupSize: wallet.groupSize(), + DishonestThreshold: wallet.groupDishonestThreshold( + se.groupParameters.HonestThreshold, + ), + Channel: se.broadcastChannel, + MembershipValidator: se.membershipValidator, + Attempt: attemptInfo, + }, ) if err != nil { return nil, 0, err @@ -437,6 +642,26 @@ func (se *signingExecutor) wallet() wallet { return se.signers[0].wallet } +func attemptIncludedMembersIndexes( + groupSize int, + excludedMembersIndexes []group.MemberIndex, +) []group.MemberIndex { + excludedMembersIndexesSet := make(map[group.MemberIndex]bool) + for _, excludedMemberIndex := range excludedMembersIndexes { + excludedMembersIndexesSet[excludedMemberIndex] = true + } + + includedMembersIndexes := make([]group.MemberIndex, 0) + for i := 0; i < groupSize; i++ { + memberIndex := group.MemberIndex(i + 1) + if !excludedMembersIndexesSet[memberIndex] { + includedMembersIndexes = append(includedMembersIndexes, memberIndex) + } + } + + return includedMembersIndexes +} + // setMetricsRecorder sets the metrics recorder for the signing executor. func (se *signingExecutor) setMetricsRecorder(recorder interface { IncrementCounter(name string, value float64) @@ -444,4 +669,11 @@ func (se *signingExecutor) setMetricsRecorder(recorder interface { RecordDuration(name string, duration time.Duration) }) { se.metricsRecorder = recorder + + // Recorders owning the process-wide signing-attempt liveness tracker + // (RFC-21 Annex B implied-f alerting) share it with this executor; + // no-op recorders simply leave the tracker disabled. + if provider, ok := recorder.(signingLivenessTrackerProvider); ok { + se.livenessTracker = provider.SigningAttemptLivenessTracker() + } } diff --git a/pkg/tbtc/signing_done.go b/pkg/tbtc/signing_done.go index 0f88b7d1bf..7b3b717054 100644 --- a/pkg/tbtc/signing_done.go +++ b/pkg/tbtc/signing_done.go @@ -7,10 +7,10 @@ import ( "sync" "time" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" ) // signingDoneReceiveBuffer is a buffer for messages received from the broadcast @@ -35,7 +35,7 @@ type signingDoneMessage struct { senderID group.MemberIndex message *big.Int attemptNumber uint64 - signature *tecdsa.Signature + signature *frost.Signature endBlock uint64 } @@ -47,23 +47,42 @@ func (sdm *signingDoneMessage) Type() string { // successful signature calculation across all signing group members. type signingDoneCheck struct { groupSize int + honestThreshold int broadcastChannel net.BroadcastChannel membershipValidator *group.MembershipValidator - receiveCtx context.Context - cancelReceiveCtx context.CancelFunc - expectedSignersCount int - doneSigners map[group.MemberIndex]*signingDoneMessage - doneSignersMutex sync.Mutex + receiveCtx context.Context + cancelReceiveCtx context.CancelFunc + // attemptMemberCount is len(attemptMembersIndexes) for the live attempt - the + // number of confirmations the legacy (non-oversized) rule waits for. + attemptMemberCount int + // oversized is true when the attempt's included set is larger than the honest + // threshold: the RFC-21 Phase 7.3 t-of-included case, where the attempt is + // signed by a t-subset so the non-subset / offline members never report done + // and the legacy all-members rule would hang. It selects the + // quorum-by-signature completion rule. When false (included == threshold, the + // pre-oversizing selector output and the whole coarse path) the legacy rule is + // used UNCHANGED. + oversized bool + // attemptTimeoutBlock is the deterministic block the attempt concludes by + // (announcementEndBlock + signingAttemptMaximumProtocolBlocks). On the oversized + // path it is returned as the result end block instead of a network-order- + // dependent max over done messages, so every honest node feeds signBatch the + // same next-signature start block (signingStartBlock = prev endBlock + interlude). + attemptTimeoutBlock uint64 + doneSigners map[group.MemberIndex]*signingDoneMessage + doneSignersMutex sync.RWMutex } func newSigningDoneCheck( groupSize int, + honestThreshold int, broadcastChannel net.BroadcastChannel, membershipValidator *group.MembershipValidator, ) *signingDoneCheck { return &signingDoneCheck{ groupSize: groupSize, + honestThreshold: honestThreshold, broadcastChannel: broadcastChannel, membershipValidator: membershipValidator, } @@ -90,14 +109,26 @@ func (sdc *signingDoneCheck) listen( // causes warnings on the channel level. sdc.receiveCtx, sdc.cancelReceiveCtx = context.WithCancel(ctx) + sdc.doneSignersMutex.Lock() + sdc.attemptMemberCount = len(attemptMembersIndexes) + // An included set larger than the honest threshold is the RFC-21 Phase 7.3 + // t-of-included case: the attempt is signed by a t-subset and the non-subset / + // offline members never report done, so the all-members rule would hang. The + // oversized path concludes on a quorum of >= honestThreshold matching + // signatures instead, with a deterministic end block. included == threshold + // (today's selector output and the whole coarse path) keeps the legacy rule + // byte-for-byte, so behavior is unchanged until participant selection oversizes + // the set. + sdc.oversized = len(attemptMembersIndexes) > sdc.honestThreshold + sdc.attemptTimeoutBlock = attemptTimeoutBlock + sdc.doneSigners = make(map[group.MemberIndex]*signingDoneMessage) + sdc.doneSignersMutex.Unlock() + messagesChan := make(chan net.Message, signingDoneReceiveBuffer) sdc.broadcastChannel.Recv(sdc.receiveCtx, func(message net.Message) { messagesChan <- message }) - sdc.expectedSignersCount = len(attemptMembersIndexes) - sdc.doneSigners = make(map[group.MemberIndex]*signingDoneMessage) - go func() { for { select { @@ -117,9 +148,9 @@ func (sdc *signingDoneCheck) listen( continue } - sdc.doneSignersMutex.Lock() - sdc.doneSigners[doneMessage.senderID] = doneMessage - sdc.doneSignersMutex.Unlock() + if !sdc.recordDoneMessage(doneMessage) { + continue + } case <-sdc.receiveCtx.Done(): return @@ -147,12 +178,12 @@ func (sdc *signingDoneCheck) signalDone( }, net.BackoffRetransmissionStrategy) } -// waitUntilAllDone blocks until it receives all the required done checks from -// members or until the passed context is done. In the first case, it returns -// the signature computed by the signing members and the block at which the -// slowest signer completed the signature computation process. If the expected -// done checks are not received on time, the function returns an error. If at -// least one signature is different from others, the function returns an error. +// waitUntilAllDone blocks until the attempt's completion rule is met or the +// passed context is done. On success it returns the agreed signature and a +// deterministic end block (the same value on every honest node): on the legacy +// path the block at which the slowest attempt member completed, on the oversized +// path the attempt timeout block. It returns errWaitDoneTimedOut if the rule is +// not met on time, and a non-nil error on a fatal divergence (legacy path only). func (sdc *signingDoneCheck) waitUntilAllDone(ctx context.Context) ( *signing.Result, uint64, @@ -169,48 +200,121 @@ func (sdc *signingDoneCheck) waitUntilAllDone(ctx context.Context) ( return nil, 0, errWaitDoneTimedOut case <-ticker.C: - result, endBlock, done, err := func() ( - *signing.Result, - uint64, - bool, - error, - ) { - sdc.doneSignersMutex.Lock() - defer sdc.doneSignersMutex.Unlock() - - if sdc.expectedSignersCount != len(sdc.doneSigners) { - return nil, 0, false, nil - } + result, endBlock, concluded, err := sdc.evaluateDone() + if err != nil { + return nil, 0, err + } + if concluded { + return result, endBlock, nil + } + } + } +} - var signature *tecdsa.Signature - var latestEndBlock uint64 - - for _, doneMessage := range sdc.doneSigners { - if signature == nil { - signature = doneMessage.signature - } else { - if !signature.Equals(doneMessage.signature) { - return nil, 0, true, fmt.Errorf( - "not matching signatures detected: [%v] and [%v]", - signature, - doneMessage.signature, - ) - } - } - - if doneMessage.endBlock > latestEndBlock { - latestEndBlock = doneMessage.endBlock - } - } +// evaluateDone snapshots the done checks collected so far and applies the +// attempt's completion rule. It returns (result, endBlock, true, nil) once the +// attempt can conclude, (nil, 0, false, nil) while still waiting, or +// (nil, 0, false, err) on a fatal divergence. The legacy (non-oversized) rule +// and the oversized t-of-included rule are kept fully separate so the legacy / +// coarse path is byte-for-byte unchanged. +func (sdc *signingDoneCheck) evaluateDone() (*signing.Result, uint64, bool, error) { + sdc.doneSignersMutex.RLock() + oversized := sdc.oversized + attemptMemberCount := sdc.attemptMemberCount + attemptTimeoutBlock := sdc.attemptTimeoutBlock + honestThreshold := sdc.honestThreshold + doneSigners := make([]*signingDoneMessage, 0, len(sdc.doneSigners)) + for _, doneMessage := range sdc.doneSigners { + doneSigners = append(doneSigners, doneMessage.clone()) + } + sdc.doneSignersMutex.RUnlock() - return &signing.Result{Signature: signature}, latestEndBlock, true, nil - }() + if oversized { + return concludeOversizedDone(doneSigners, honestThreshold, attemptTimeoutBlock) + } + return concludeLegacyDone(doneSigners, attemptMemberCount) +} - if done { - return result, endBlock, err - } +// concludeLegacyDone is the pre-7.3 rule, UNCHANGED: conclude once every attempt +// member confirmed, require all signatures equal, and return the max end block. +// The attemptMemberCount > 0 guard rejects the pre-listen state (no attempt +// configured) so an empty done set is never read as success. +func concludeLegacyDone( + doneSigners []*signingDoneMessage, + attemptMemberCount int, +) (*signing.Result, uint64, bool, error) { + if attemptMemberCount == 0 || len(doneSigners) != attemptMemberCount { + return nil, 0, false, nil + } + + var signature *frost.Signature + var latestEndBlock uint64 + for _, doneMessage := range doneSigners { + if signature == nil { + signature = doneMessage.signature + } else if !signature.Equals(doneMessage.signature) { + return nil, 0, false, fmt.Errorf( + "not matching signatures detected: [%v] and [%v]", + signature, + doneMessage.signature, + ) + } + + if doneMessage.endBlock > latestEndBlock { + latestEndBlock = doneMessage.endBlock + } + } + + return &signing.Result{Signature: signature}, latestEndBlock, true, nil +} + +// concludeOversizedDone is the RFC-21 Phase 7.3 t-of-included rule: bucket the +// done checks by signature (one done message per sender, so each member is in +// exactly one bucket) and conclude once a bucket holds >= honestThreshold +// distinct senders - the minimum that proves a valid threshold signature. +// Minority buckets (divergent or adversarial signatures) are IGNORED, never +// fatal, so a single bad done message cannot fracture the group. The end block +// is the deterministic attempt timeout block, not a network-order-dependent max, +// so every honest node returns the same value for batch scheduling. +func concludeOversizedDone( + doneSigners []*signingDoneMessage, + honestThreshold int, + attemptTimeoutBlock uint64, +) (*signing.Result, uint64, bool, error) { + if honestThreshold <= 0 { + return nil, 0, false, nil + } + + bucketSig := map[string]*frost.Signature{} + bucketCount := map[string]int{} + for _, doneMessage := range doneSigners { + serialized := doneMessage.signature.Serialize() + key := string(serialized[:]) + if _, ok := bucketSig[key]; !ok { + bucketSig[key] = doneMessage.signature + } + bucketCount[key]++ + } + + var quorumSig *frost.Signature + quorums := 0 + for key, count := range bucketCount { + if count >= honestThreshold { + quorums++ + quorumSig = bucketSig[key] } } + + // Exactly one >= t bucket is the only reachable outcome under honest majority + // (honestThreshold > groupSize/2 means two disjoint >= t buckets cannot + // coexist). It carries the one valid signature and concludes deterministically. + // quorums == 0 keeps waiting; quorums > 1 is unreachable and intentionally NOT + // concluded (the attempt fails via the ctx timeout rather than picking a bucket + // nondeterministically - a bare done-message split is not coordinator blame). + if quorums == 1 { + return &signing.Result{Signature: quorumSig}, attemptTimeoutBlock, true, nil + } + return nil, 0, false, nil } // isValidDoneMessage validates the given signingDoneMessage in the context @@ -222,12 +326,6 @@ func (sdc *signingDoneCheck) isValidDoneMessage( attemptNumber uint64, attemptTimeoutBlock uint64, ) bool { - _, signerDone := sdc.doneSigners[doneMessage.senderID] - if signerDone { - // only one done message allowed - return false - } - if !sdc.membershipValidator.IsValidMembership( doneMessage.senderID, senderPublicKey, @@ -253,3 +351,41 @@ func (sdc *signingDoneCheck) isValidDoneMessage( return true } + +func (sdc *signingDoneCheck) recordDoneMessage( + doneMessage *signingDoneMessage, +) bool { + sdc.doneSignersMutex.Lock() + defer sdc.doneSignersMutex.Unlock() + + if _, signerDone := sdc.doneSigners[doneMessage.senderID]; signerDone { + // Only one done message is allowed for the given signer. + return false + } + + sdc.doneSigners[doneMessage.senderID] = doneMessage.clone() + return true +} + +func (sdm *signingDoneMessage) clone() *signingDoneMessage { + if sdm == nil { + return nil + } + + result := &signingDoneMessage{ + senderID: sdm.senderID, + attemptNumber: sdm.attemptNumber, + endBlock: sdm.endBlock, + } + + if sdm.message != nil { + result.message = new(big.Int).Set(sdm.message) + } + + if sdm.signature != nil { + signatureCopy := *sdm.signature + result.signature = &signatureCopy + } + + return result +} diff --git a/pkg/tbtc/signing_done_test.go b/pkg/tbtc/signing_done_test.go index d946052584..6a08dece7c 100644 --- a/pkg/tbtc/signing_done_test.go +++ b/pkg/tbtc/signing_done_test.go @@ -14,12 +14,11 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/net/local" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" ) // TestSigningDoneCheck is a happy path test. @@ -49,11 +48,7 @@ func TestSigningDoneCheck(t *testing.T) { attemptTimeoutBlock := uint64(1000) attemptMemberIndexes := memberIndexes[:groupParameters.HonestThreshold] result := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), } type outcome struct { @@ -171,11 +166,7 @@ func TestSigningDoneCheck_MissingConfirmation(t *testing.T) { attemptTimeoutBlock := uint64(1000) attemptMemberIndexes := memberIndexes[:groupParameters.HonestThreshold] result := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), } doneCheck.listen( @@ -234,18 +225,10 @@ func TestSigningDoneCheck_AnotherSignature(t *testing.T) { attemptTimeoutBlock := uint64(1000) attemptMemberIndexes := memberIndexes[:groupParameters.HonestThreshold] correctResult := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), } incorrectResult := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(201), - S: big.NewInt(300), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(201), big.NewInt(300)), } doneCheck.listen( @@ -298,16 +281,198 @@ func TestSigningDoneCheck_AnotherSignature(t *testing.T) { } } +// TestSigningDoneCheck_ThresholdSubsetConcludes covers RFC-21 Phase 7.3 +// t-of-included finalize: the included set is oversized (larger than the honest +// threshold) and only the t-subset that actually signed reports done - the rest +// are offline. The check must conclude on a quorum of t matching signatures +// rather than hanging for the absent members, and must return the DETERMINISTIC +// attempt timeout block as the end block (not a network-order-dependent max over +// the done messages, which would desync batch scheduling across nodes). +func TestSigningDoneCheck_ThresholdSubsetConcludes(t *testing.T) { + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + doneCheck := setupSigningDoneCheck(t, groupParameters) + + memberIndexes := make([]group.MemberIndex, doneCheck.groupSize) + for i := range memberIndexes { + memberIndexes[i] = group.MemberIndex(i + 1) + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelCtx() + + message := big.NewInt(100) + attemptNumber := uint64(1) + attemptTimeoutBlock := uint64(1000) + // Oversized included set: ALL five members are included, but only the first + // three (the chosen signing subset) report done; members 4 and 5 are offline. + attemptMemberIndexes := memberIndexes + result := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), + } + + doneCheck.listen( + ctx, + message, + attemptNumber, + attemptTimeoutBlock, + attemptMemberIndexes, + ) + + // The three reporters carry DIFFERENT end blocks (501, 502, 503); the result + // must still be the deterministic attempt timeout block, proving the returned + // end block does not depend on which/how-many done messages arrived. + for i := 1; i <= groupParameters.HonestThreshold; i++ { + err := doneCheck.signalDone( + ctx, + uint8(i), + message, + attemptNumber, + result, + 500+uint64(i), + ) + if err != nil { + t.Fatal(err) + } + } + + returnedResult, endBlock, err := doneCheck.waitUntilAllDone(ctx) + if err != nil { + t.Fatalf("expected conclusion on the t-subset, got error: [%v]", err) + } + if returnedResult == nil || !result.Signature.Equals(returnedResult.Signature) { + t.Fatalf("unexpected result: [%v]", returnedResult) + } + testutils.AssertIntsEqual(t, "end block", int(attemptTimeoutBlock), int(endBlock)) +} + +// TestSigningDoneCheck_OversizedIgnoresMinorityDivergentSignature proves the +// oversized rule is robust to a minority adversary: a single done message +// carrying a DIFFERENT signature must not fail the check or fracture the group. +// t members report the correct signature (a quorum) while one reports a +// divergent one; the check concludes on the quorum and ignores the minority, +// rather than erroring the way the legacy all-members match-all rule would. +func TestSigningDoneCheck_OversizedIgnoresMinorityDivergentSignature(t *testing.T) { + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + doneCheck := setupSigningDoneCheck(t, groupParameters) + + memberIndexes := make([]group.MemberIndex, doneCheck.groupSize) + for i := range memberIndexes { + memberIndexes[i] = group.MemberIndex(i + 1) + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelCtx() + + message := big.NewInt(100) + attemptNumber := uint64(1) + attemptTimeoutBlock := uint64(1000) + attemptMemberIndexes := memberIndexes // oversized (5 > threshold 3) + correctResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), + } + divergentResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(201), big.NewInt(300)), + } + + doneCheck.listen(ctx, message, attemptNumber, attemptTimeoutBlock, attemptMemberIndexes) + + // Members 1..3 report the correct signature (a quorum); member 4 reports a + // divergent one. + for i := 1; i <= groupParameters.HonestThreshold; i++ { + if err := doneCheck.signalDone(ctx, uint8(i), message, attemptNumber, correctResult, 100); err != nil { + t.Fatal(err) + } + } + if err := doneCheck.signalDone(ctx, uint8(4), message, attemptNumber, divergentResult, 100); err != nil { + t.Fatal(err) + } + + returnedResult, endBlock, err := doneCheck.waitUntilAllDone(ctx) + if err != nil { + t.Fatalf("a minority divergent signature must be ignored, not fatal; got error: [%v]", err) + } + if returnedResult == nil || !correctResult.Signature.Equals(returnedResult.Signature) { + t.Fatalf("expected the quorum signature, got: [%v]", returnedResult) + } + testutils.AssertIntsEqual(t, "end block", int(attemptTimeoutBlock), int(endBlock)) +} + +// TestSigningDoneCheck_OversizedSplitBelowQuorumTimesOut covers the no-quorum +// case (e.g. a coordinator equivocation splitting honest nodes across two +// signatures, plus offline members): with the reporters split below the +// threshold in every signature bucket, no bucket reaches t, so the check must +// time out rather than conclude on a non-quorum subset. +func TestSigningDoneCheck_OversizedSplitBelowQuorumTimesOut(t *testing.T) { + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + doneCheck := setupSigningDoneCheck(t, groupParameters) + + memberIndexes := make([]group.MemberIndex, doneCheck.groupSize) + for i := range memberIndexes { + memberIndexes[i] = group.MemberIndex(i + 1) + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 1*time.Second) + defer cancelCtx() + + message := big.NewInt(100) + attemptNumber := uint64(1) + attemptTimeoutBlock := uint64(1000) + attemptMemberIndexes := memberIndexes // oversized (5 > threshold 3) + resultA := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), + } + resultB := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(201), big.NewInt(300)), + } + + doneCheck.listen(ctx, message, attemptNumber, attemptTimeoutBlock, attemptMemberIndexes) + + // 2 report signature A, 2 report signature B, 1 offline: no bucket reaches t=3. + for _, i := range []uint8{1, 2} { + if err := doneCheck.signalDone(ctx, i, message, attemptNumber, resultA, 100); err != nil { + t.Fatal(err) + } + } + for _, i := range []uint8{3, 4} { + if err := doneCheck.signalDone(ctx, i, message, attemptNumber, resultB, 100); err != nil { + t.Fatal(err) + } + } + + returnedResult, endBlock, err := doneCheck.waitUntilAllDone(ctx) + if returnedResult != nil { + t.Errorf("expected nil result on a below-quorum split, has [%v]", returnedResult) + } + testutils.AssertIntsEqual(t, "end block", 0, int(endBlock)) + testutils.AssertErrorsSame(t, errWaitDoneTimedOut, err) +} + // signingDoneCheckComponents holds the shared state used to construct one or // more signingDoneCheck instances that communicate over the same channel. type signingDoneCheckComponents struct { groupSize int + honestThreshold int broadcastChannel net.BroadcastChannel membershipValidator *group.MembershipValidator } func (c *signingDoneCheckComponents) newCheck() *signingDoneCheck { - return newSigningDoneCheck(c.groupSize, c.broadcastChannel, c.membershipValidator) + return newSigningDoneCheck(c.groupSize, c.honestThreshold, c.broadcastChannel, c.membershipValidator) } // setupSigningDoneCheckComponents builds the shared channel and validator @@ -358,6 +523,7 @@ func setupSigningDoneCheckComponents( return &signingDoneCheckComponents{ groupSize: groupParameters.GroupSize, + honestThreshold: groupParameters.HonestThreshold, broadcastChannel: broadcastChannel, membershipValidator: membershipValidator, } diff --git a/pkg/tbtc/signing_loop.go b/pkg/tbtc/signing_loop.go index 7e787f1975..754f932424 100644 --- a/pkg/tbtc/signing_loop.go +++ b/pkg/tbtc/signing_loop.go @@ -4,18 +4,16 @@ import ( "context" "crypto/sha256" "encoding/binary" + "errors" "fmt" "math/big" - "math/rand" - "sort" "github.com/keep-network/keep-core/pkg/protocol/announcer" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa/retry" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" "golang.org/x/exp/slices" ) @@ -45,6 +43,17 @@ func signingAttemptMaximumBlocks() uint { signingAttemptCoolDownBlocks } +// signingAttemptSeed computes a deterministic seed used for retry and +// coordinator selection for a given signed message. +func signingAttemptSeed(message *big.Int) int64 { + // Compute the 8-byte seed needed for the random retry algorithm. We take + // the first 8 bytes of the hash of the signed message. This allows us to + // not care in this piece of the code about the length of the message and + // how this message is proposed. + messageSha256 := sha256.Sum256(message.Bytes()) + return int64(binary.BigEndian.Uint64(messageSha256[:8])) +} + // signingAnnouncer represents a component responsible for exchanging readiness // announcements for the given signing attempt of the given message. type signingAnnouncer interface { @@ -84,6 +93,11 @@ type signingRetryLoop struct { message *big.Int + // roastSessionID is the STABLE ROAST session id (no attempt number) keying + // the ROAST transition-record registry + participant-selector lookup across + // this signing's attempts. Empty in deployments that do not drive ROAST. + roastSessionID string + signingGroupMemberIndex group.MemberIndex signingGroupOperators chain.Addresses @@ -95,12 +109,50 @@ type signingRetryLoop struct { attemptStartBlock uint64 attemptSeed int64 + // roastAttemptNumber is the 0-based COMMITTED ROAST attempt index: it advances + // only when an iteration reaches selection/observe (a committed attempt), NOT + // on the block-timing/announcement/minority-readiness skips that tick + // attemptCounter. The ROAST transition chain keys off it (observe context, + // freshness, consume) so a skipped loop iteration does not break the + // consecutive-transition chain across honest nodes (RFC-21 Phase 7.3 PR2b-1b). + roastAttemptNumber uint + doneCheck signingDoneCheckStrategy + + // participantSelector dispatches qualified-operator selection. The default + // build uses the legacy retry shuffle; the frost_roast_retry build installs the + // ROAST-driven selector (signing_loop_selector_frost_roast_retry.go), which + // consumes the transition record the exchange produces -- now carrying the real + // blame evidence the coarse path captures (RFC-21 Phase 7.3 PR2b-2 step 2) -- + // and falls back to the shuffle only on the uniform initial/inactive condition. + participantSelector signingParticipantSelector + + // transitionController, when non-nil, owns the session-scoped ROAST + // transition machinery for this signer (RFC-21 Phase 7.3 PR2b-1b): observing + // each attempt so the signer holds a handle to verify the attempt's + // transition bundle and run NextAttempt for participant selection. nil in the + // default build and in deployments that do not drive ROAST retry, in which + // case the loop runs no transition steps. + transitionController roastTransitionController + + // attemptOutcomeReporter, when non-nil, receives the terminal outcome + // of every network-wide signing attempt this loop observes (RFC-21 + // Annex B implied-f liveness alerting). An outcome is reported when an + // attempt reaches a terminal disposition: minority readiness after a + // completed announcement, a failed protocol run, a failed done-check + // exchange, or success. Mechanical iterations that never sample the + // group - block-timing skips, local announcement errors, context + // cancellation, and a members-selection error (which terminates the + // whole loop, not the attempt) - are deliberately not reported, so + // the rate feeding the Annex B sampling model is not diluted by + // local noise. + attemptOutcomeReporter func(success bool) } func newSigningRetryLoop( logger log.StandardLogger, message *big.Int, + roastSessionID string, initialStartBlock uint64, signingGroupMemberIndex group.MemberIndex, signingGroupOperators chain.Addresses, @@ -108,33 +160,61 @@ func newSigningRetryLoop( announcer signingAnnouncer, doneCheck signingDoneCheckStrategy, ) *signingRetryLoop { - // Compute the 8-byte seed needed for the random retry algorithm. We take - // the first 8 bytes of the hash of the signed message. This allows us to - // not care in this piece of the code about the length of the message and - // how this message is proposed. - messageSha256 := sha256.Sum256(message.Bytes()) - attemptSeed := int64(binary.BigEndian.Uint64(messageSha256[:8])) - return &signingRetryLoop{ logger: logger, message: message, + roastSessionID: roastSessionID, signingGroupMemberIndex: signingGroupMemberIndex, signingGroupOperators: signingGroupOperators, groupParameters: groupParameters, announcer: announcer, attemptCounter: 0, attemptStartBlock: initialStartBlock, - attemptSeed: attemptSeed, + attemptSeed: signingAttemptSeed(message), doneCheck: doneCheck, + participantSelector: defaultSigningParticipantSelector(), + } +} + +// setAttemptOutcomeReporter installs the attempt-outcome reporter. See the +// attemptOutcomeReporter field for the reporting contract. +func (srl *signingRetryLoop) setAttemptOutcomeReporter( + reporter func(success bool), +) { + srl.attemptOutcomeReporter = reporter +} + +// setTransitionController installs the ROAST transition controller. A nil +// controller leaves the loop running no transition steps (the default). +func (srl *signingRetryLoop) setTransitionController( + controller roastTransitionController, +) { + srl.transitionController = controller +} + +func (srl *signingRetryLoop) reportAttemptOutcome(success bool) { + if srl.attemptOutcomeReporter != nil { + srl.attemptOutcomeReporter(success) } } // signingAttemptParams represents parameters of a signing attempt. type signingAttemptParams struct { + // number is the attempt number the active signing keys its AttemptContext, + // coordinator election, and session id off. Under active ROAST retry this is + // the COMMITTED roast attempt number (roastAttemptNumber+1), so the active + // signing context matches the observe/transition context the loop built for + // the same committed attempt; otherwise it is the block-paced attemptCounter + // (legacy, unchanged). RFC-21 Phase 7.3 PR2b-1b. number uint startBlock uint64 timeoutBlock uint64 excludedMembersIndexes []group.MemberIndex + // transientlyParkedMembersIndexes are the members the prior ROAST transition + // parked for THIS attempt only. The active signing stamps them onto its + // AttemptContext so it is byte-identical to the observe context (which carries + // the same parking); empty for the legacy path. + transientlyParkedMembersIndexes []group.MemberIndex } // signingAttemptFn represents a function performing a signing attempt. @@ -305,10 +385,26 @@ func (srl *signingRetryLoop) start( len(readyMembersIndexes), unreadyMembersIndexes, ) + srl.reportAttemptOutcome(false) continue } - excludedMembersIndexes, err := srl.performMembersSelection( + // RFC-21 Phase 7.3 PR2b-1b: if this seat fell behind the group's committed + // ROAST attempt chain -- its listener received a transition for an attempt + // it never observed because it skipped a window peers committed -- selecting + // now would produce a divergent included set (the fracture class). Fail + // closed before selection; the outer layer retries the whole signing from a + // fresh election. The check is deterministic per seat (a nil controller or + // inactive ROAST is never lost-sync). + if srl.transitionController != nil && srl.transitionController.HasLostSync() { + return nil, fmt.Errorf( + "cannot select members for attempt [%v]: lost ROAST sync "+ + "(received a transition for an unobserved attempt); fail closed", + srl.attemptCounter, + ) + } + + includedMembersIndexes, excludedMembersIndexes, transientlyParkedMembersIndexes, err := srl.performMembersSelection( readyMembersIndexes, ) if err != nil { @@ -319,13 +415,33 @@ func (srl *signingRetryLoop) start( ) } - includedMembersIndexes := make([]group.MemberIndex, 0) - for i := range srl.signingGroupOperators { - memberIndex := group.MemberIndex(i + 1) - if !slices.Contains(excludedMembersIndexes, memberIndex) { - includedMembersIndexes = append(includedMembersIndexes, memberIndex) - } + // committedRoastAttemptNumber is this committed attempt's 0-based ROAST + // index, captured BEFORE the advance below so both the observe context and + // the active signing context key off the same value (the observe Attempt is + // stamped Number = committedRoastAttemptNumber+1, so the active attempt must + // use the same number to stay byte-identical). + committedRoastAttemptNumber := srl.roastAttemptNumber + + // RFC-21 Phase 7.3 PR2b-1b: record a local observe binding for this + // committed ROAST attempt BEFORE the skip branch, so every local seat -- + // including one excluded from this attempt -- holds a handle to verify the + // attempt's transition bundle and run NextAttempt for the next attempt's + // selection. The observe context carries the transiently-parked set, so a + // one-attempt park is reinstated next time rather than made permanent. + if srl.transitionController != nil { + srl.transitionController.BeginObservedAttempt( + committedRoastAttemptNumber, + includedMembersIndexes, + excludedMembersIndexes, + transientlyParkedMembersIndexes, + ) } + // This iteration reached selection/observe, so it COMMITTED to a ROAST + // attempt: advance the committed ROAST attempt number (the block-timing, + // announcement, and minority-readiness skips above did not). Every seat -- + // included or excluded -- advances identically, keeping the transition + // chain consecutive across honest nodes. + srl.roastAttemptNumber++ attemptSkipped := slices.Contains( excludedMembersIndexes, @@ -354,13 +470,49 @@ func (srl *signingRetryLoop) start( srl.attemptCounter, ) + // RFC-21 Phase 7.3 PR2b-1b: when ROAST retry is runtime-active, the + // active signing attempt must key off the COMMITTED roast attempt index + // (+ parking) so its AttemptContext is byte-identical to the + // observe/transition context this seat built for the same committed + // attempt above. Keyed off attemptCounter, the two would diverge after a + // pre-selection skip or whenever a member is parked, binding signing + // messages and transition bundles to different context hashes. ROAST + // inactive keeps the block-paced attemptCounter (legacy, unchanged). The + // gate is the PER-SEAT RoastRetryActiveForMember predicate (PR2b-1.5): a + // multi-seat operator may have one seat registered and another not, so + // activation is a per-member property; it stays deterministic per seat. + activeAttemptNumber := srl.attemptCounter + if signing.RoastRetryActiveForMember(srl.signingGroupMemberIndex) { + activeAttemptNumber = committedRoastAttemptNumber + 1 + } + result, endBlock, err := signingAttemptFn(&signingAttemptParams{ - number: srl.attemptCounter, - startBlock: announcementEndBlock, - timeoutBlock: timeoutBlock, - excludedMembersIndexes: excludedMembersIndexes, + number: activeAttemptNumber, + startBlock: announcementEndBlock, + timeoutBlock: timeoutBlock, + excludedMembersIndexes: excludedMembersIndexes, + transientlyParkedMembersIndexes: transientlyParkedMembersIndexes, }) if err != nil { + // RFC-21 Phase 7.3 PR2b-1.5 (Codex/Gemini consult): a TERMINAL + // orchestration error is a STATIC condition no future attempt can + // resolve (e.g. multi-seat interactive ROAST orchestration that is not + // yet member-safe). Retrying is futile -- every attempt re-derives the + // same outcome -- and would spin until timeout while synthesizing garbage + // failed-attempt transitions via OnAttemptFailed below. Abort the loop + // immediately, BEFORE the retry/transition machinery; the outer + // wallet-signing layer gives up cleanly for this action. Coarse fallback + // is not an option here: interactive<->coarse mixing fractures the group. + if errors.Is(err, signing.ErrTerminalSigningFailure) { + srl.logger.Errorf( + "[member:%v] terminal signing failure on attempt [%v]: [%v]; "+ + "aborting retry loop", + srl.signingGroupMemberIndex, + srl.attemptCounter, + err, + ) + return nil, err + } srl.logger.Warnf( "[member:%v] failed attempt [%v]: [%v]; "+ "starting next attempt", @@ -368,9 +520,30 @@ func (srl *signingRetryLoop) start( srl.attemptCounter, err, ) + // RFC-21 Phase 7.3 PR2b-1b: this seat committed to and failed the + // attempt, so drive the transition exchange (forced snapshot, and + // the elected coordinator's aggregation) for the next attempt's + // selection. Inert until the selector consumes records (C3). + if srl.transitionController != nil { + srl.transitionController.OnAttemptFailed( + srl.attemptCounter, + timeoutBlock, + ) + } + srl.reportAttemptOutcome(false) continue } + // RFC-21 Phase 7.3 PR2b-1b: the protocol round succeeded locally (a valid + // signature aggregated), so clear this seat's observe binding for the + // attempt -- no failure transition may be synthesized or stored for a + // succeeded attempt. If the done-check below then fails, the next attempt + // finds no fresh transition and fails closed (the honest outcome) instead + // of consuming a peer's failure transition for an attempt this seat won. + if srl.transitionController != nil { + srl.transitionController.OnAttemptSucceeded() + } + srl.logger.Infof( "[member:%v] exchanging signing done checks for attempt [%v]", srl.signingGroupMemberIndex, @@ -393,6 +566,11 @@ func (srl *signingRetryLoop) start( srl.attemptCounter, err, ) + // A failed done signal is a local fault, but this loop + // abandons the attempt here, so from this node's sampler + // the attempt did not complete; the baseline calibration + // absorbs this as benign noise. + srl.reportAttemptOutcome(false) continue } } else { @@ -413,6 +591,7 @@ func (srl *signingRetryLoop) start( srl.attemptCounter, err, ) + srl.reportAttemptOutcome(false) continue } @@ -421,6 +600,8 @@ func (srl *signingRetryLoop) start( inactiveMembers: unreadyMembersIndexes, } + srl.reportAttemptOutcome(true) + return &signingRetryLoopResult{ result: result, activityReport: activityReport, @@ -430,133 +611,72 @@ func (srl *signingRetryLoop) start( } } -// performMembersSelection runs the member selection process whose result -// is a list of members' indexes that should be excluded by the client -// for the given signing attempt. +// performMembersSelection runs the member selection process for the given +// signing attempt, returning the included member indices (the members that +// participate), the excluded ones (everyone else), and the transiently-parked +// subset, each sorted ascending. // -// The member selection process is done based on the list of ready members -// provided as the readyMembersIndexes argument. This list is used twice: -// -// First, the algorithm determining the qualified operators set uses the -// ready members list to build an input consisting of only active operators. -// This way we guarantee that the qualified operators set contains only -// ready and active operators that will actually take part in the signing -// attempt. -// -// Second, the ready members list is used to determine a list of excluded -// members. The excluded members list is built using the qualified operators -// set. The algorithm that determines the qualified operators set does not -// care about an exact mapping between operators and controlled members but -// relies on the members count solely. That means the information about -// readiness of specific members controlled by the given operators is not -// included in the resulting qualified operators set. In order to properly -// decide about inclusion or exclusion of specific members of a given -// qualified operator, we must take the ready members list into account. +// Selection is dispatched through participantSelector (RFC-21 Phase 6.4). As of +// Phase 7.3 PR2b-1a it is MEMBER-LEVEL; PR2b-1b's ROAST implementation returns +// the transition's IncludedSet + TransientlyParked directly (keyed by the +// COMMITTED roastAttemptNumber, not the block-paced attemptCounter). The excluded +// set is the complement of the included set over the whole signing group, so the +// two partition [1, groupSize]; the parked set is the subset of the excluded set +// that the next attempt reinstates. func (srl *signingRetryLoop) performMembersSelection( readyMembersIndexes []group.MemberIndex, -) ([]group.MemberIndex, error) { - qualifiedOperatorsSet, err := srl.qualifiedOperatorsSet(readyMembersIndexes) - if err != nil { - return nil, fmt.Errorf("cannot get qualified operators: [%w]", err) - } - - // Exclude all members controlled by the operators that were not - // qualified for the current attempt. - return srl.excludedMembersIndexes( - qualifiedOperatorsSet, - readyMembersIndexes, - ), nil -} - -// qualifiedOperatorsSet returns a set of operators qualified to participate -// in the given signing attempt. The set of qualified operators is taken -// from the set of active operators who announced readiness through -// their controlled signing group members. -func (srl *signingRetryLoop) qualifiedOperatorsSet( - readyMembersIndexes []group.MemberIndex, -) (map[chain.Address]bool, error) { - // The retry algorithm expects that we count retries from 0. Since - // the first invocation of the algorithm will be for `attemptCounter == 1` - // we need to subtract one while determining the number of the given retry. +) ([]group.MemberIndex, []group.MemberIndex, []group.MemberIndex, error) { + // The legacy retry algorithm counts retries from 0. The first invocation is + // for attemptCounter == 1, so the legacy retry count is attemptCounter - 1. + // The ROAST path instead keys off the committed roastAttemptNumber. retryCount := srl.attemptCounter - 1 - var readySigningGroupOperators []chain.Address - for _, memberIndex := range readyMembersIndexes { - readySigningGroupOperators = append( - readySigningGroupOperators, - srl.signingGroupOperators[memberIndex-1], - ) - } - - qualifiedOperators, err := retry.EvaluateRetryParticipantsForSigning( - readySigningGroupOperators, + selection, err := srl.participantSelector.Select( + readyMembersIndexes, + srl.signingGroupOperators, srl.attemptSeed, retryCount, + srl.roastAttemptNumber, uint(srl.groupParameters.HonestThreshold), + srl.roastSessionID, + srl.signingGroupMemberIndex, ) if err != nil { - return nil, fmt.Errorf( - "random operator selection failed: [%w]", + return nil, nil, nil, fmt.Errorf( + "participant selection failed: [%w]", err, ) } - return chain.Addresses(qualifiedOperators).Set(), nil + excludedMembersIndexes := membersComplement( + selection.includedMembersIndexes, + len(srl.signingGroupOperators), + ) + + return selection.includedMembersIndexes, + excludedMembersIndexes, + selection.transientlyParkedMembersIndexes, + nil } -// excludedMembersIndexes returns a list of excluded members' indexes for -// the given qualified operators set. -func (srl *signingRetryLoop) excludedMembersIndexes( - qualifiedOperatorsSet map[chain.Address]bool, - readyMembersIndexes []group.MemberIndex, +// membersComplement returns the member indices in [1, groupSize] that are NOT +// in the included set, sorted ascending. Together with the included set it +// partitions the whole signing group. +func membersComplement( + includedMembersIndexes []group.MemberIndex, + groupSize int, ) []group.MemberIndex { - includedMembersIndexes := make([]group.MemberIndex, 0) - excludedMembersIndexes := make([]group.MemberIndex, 0) - for i, operator := range srl.signingGroupOperators { - memberIndex := group.MemberIndex(i + 1) - - if qualifiedOperatorsSet[operator] && - slices.Contains(readyMembersIndexes, memberIndex) { - includedMembersIndexes = append( - includedMembersIndexes, - memberIndex, - ) - } else { - excludedMembersIndexes = append( - excludedMembersIndexes, - memberIndex, - ) - } + includedSet := make(map[group.MemberIndex]bool, len(includedMembersIndexes)) + for _, memberIndex := range includedMembersIndexes { + includedSet[memberIndex] = true } - // Make sure we always use just the smallest required count of - // signing members for performance reasons - if len(includedMembersIndexes) > srl.groupParameters.HonestThreshold { - // #nosec G404 (insecure random number source (rand)) - // Shuffling does not require secure randomness. - rng := rand.New(rand.NewSource( - srl.attemptSeed + int64(srl.attemptCounter), - )) - // Sort in ascending order just in case. - sort.Slice(includedMembersIndexes, func(i, j int) bool { - return includedMembersIndexes[i] < includedMembersIndexes[j] - }) - // Shuffle the included members slice to randomize the - // selection of additionally excluded members. - rng.Shuffle(len(includedMembersIndexes), func(i, j int) { - includedMembersIndexes[i], includedMembersIndexes[j] = - includedMembersIndexes[j], includedMembersIndexes[i] - }) - // Get the surplus of included members and add them to - // the excluded members list. - excludedMembersIndexes = append( - excludedMembersIndexes, - includedMembersIndexes[srl.groupParameters.HonestThreshold:]..., - ) - // Sort the resulting excluded members list in ascending order. - sort.Slice(excludedMembersIndexes, func(i, j int) bool { - return excludedMembersIndexes[i] < excludedMembersIndexes[j] - }) + excludedMembersIndexes := make([]group.MemberIndex, 0, groupSize) + for i := 0; i < groupSize; i++ { + memberIndex := group.MemberIndex(i + 1) + if !includedSet[memberIndex] { + excludedMembersIndexes = append(excludedMembersIndexes, memberIndex) + } } return excludedMembersIndexes diff --git a/pkg/tbtc/signing_loop_active_attempt_frost_roast_retry_test.go b/pkg/tbtc/signing_loop_active_attempt_frost_roast_retry_test.go new file mode 100644 index 0000000000..a1a52b03c7 --- /dev/null +++ b/pkg/tbtc/signing_loop_active_attempt_frost_roast_retry_test.go @@ -0,0 +1,280 @@ +//go:build frost_roast_retry + +package tbtc + +import ( + "context" + "fmt" + "math/big" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// TestSigningRetryLoop_ActiveAttemptUsesCommittedRoastNumber asserts that under +// active ROAST retry the active signing attempt keys its number off the COMMITTED +// roast attempt index (roastAttemptNumber+1), NOT the block-paced attemptCounter, +// so the active signing AttemptContext is byte-identical to the observe/transition +// context the loop built for the same committed attempt (Codex's PR2b-1b review +// P1: otherwise the two bind to different context hashes after a skip). +// +// A pre-selection minority-readiness skip makes attemptCounter (2) diverge from +// the committed roast number (1); the active attempt must report 1, not 2. +func TestSigningRetryLoop_ActiveAttemptUsesCommittedRoastNumber(t *testing.T) { + t.Setenv(signing.RoastRetryReadinessOptInEnvVar, "true") + signing.ResetRoastRetryRegistrationForTest() + t.Cleanup(signing.ResetRoastRetryRegistrationForTest) + signing.RegisterRoastRetryCoordinator(signing.RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + if !signing.RoastRetryActive() { + // In a frost_roast_retry && !frost_native build there is no transition + // producer, so RoastRetryActive is false and the active attempt stays on the + // legacy attemptCounter -- the committed-number behavior this test asserts + // only exists with a producer (frost_native). Skip rather than fail. + t.Skip("requires a transition producer (frost_native); RoastRetryActive is false without one") + } + + message := big.NewInt(100) + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(sessionID string) ([]group.MemberIndex, error) { + // Loop attempt 1: minority readiness (< honest threshold) -> skipped + // BEFORE selection, so attemptCounter advances but roastAttemptNumber + // does not. Loop attempt 2: full -> committed (attemptCounter=2, committed + // roast index 0 -> active number 1). + if sessionID == fmt.Sprintf("%v-%v", message, 1) { + return []group.MemberIndex{1, 2}, nil + } + return []group.MemberIndex{1, 2, 3}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + } + retryLoop := newSigningRetryLoop( + &testutils.MockLogger{}, + message, + "", + 200, + 1, + chain.Addresses{"address-1", "address-2", "address-3"}, + &GroupParameters{GroupSize: 3, HonestThreshold: 3}, + announcer, + doneCheck, + ) + + var captured *signingAttemptParams + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := retryLoop.start( + ctx, + func(context.Context, uint64) error { return nil }, + func() (uint64, error) { return 200, nil }, + func(params *signingAttemptParams) (*signing.Result, uint64, error) { + captured = params + return testResult, 215, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured == nil { + t.Fatal("the committed signing attempt was never executed") + } + // The block-paced attemptCounter is 2 (one skip + one commit); the committed + // roast number is 1. The active attempt must use the committed number so its + // context matches the observe context the loop built at committed index 0. + if captured.number != 1 { + t.Fatalf( + "active attempt must key off the committed roast number (1), not the "+ + "block-paced attemptCounter (2); got %d", + captured.number, + ) + } +} + +// parkingSelector is a participant selector that returns a fixed included + +// transiently-parked set, so a loop test can assert the parking threads from +// selection through signingAttemptParams to the active attempt without standing +// up a full transition record. +type parkingSelector struct { + included []group.MemberIndex + parked []group.MemberIndex +} + +func (s parkingSelector) Select( + _ []group.MemberIndex, + _ chain.Addresses, + _ int64, + _ uint, + _ uint, + _ uint, + _ string, + _ group.MemberIndex, +) (participantSelection, error) { + return participantSelection{ + includedMembersIndexes: s.included, + transientlyParkedMembersIndexes: s.parked, + }, nil +} + +// TestSigningRetryLoop_ActiveAttemptCarriesParking asserts the transiently-parked +// set produced by selection threads through signingAttemptParams to the active +// signing attempt (and thus onto its AttemptContext), so the active context's +// parking matches the observe context's. A regression dropping the field would +// make a one-attempt park permanent (the B1 hazard) on the active path. +func TestSigningRetryLoop_ActiveAttemptCarriesParking(t *testing.T) { + t.Setenv(signing.RoastRetryReadinessOptInEnvVar, "true") + signing.ResetRoastRetryRegistrationForTest() + t.Cleanup(signing.ResetRoastRetryRegistrationForTest) + signing.RegisterRoastRetryCoordinator(signing.RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + message := big.NewInt(100) + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(string) ([]group.MemberIndex, error) { + return []group.MemberIndex{1, 2, 3}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + } + retryLoop := newSigningRetryLoop( + &testutils.MockLogger{}, + message, + "", + 200, + 1, + chain.Addresses{"address-1", "address-2", "address-3"}, + &GroupParameters{GroupSize: 3, HonestThreshold: 3}, + announcer, + doneCheck, + ) + // Park member 3; keep the local seat (1) included so it reaches the active + // attempt. + retryLoop.participantSelector = parkingSelector{ + included: []group.MemberIndex{1, 2}, + parked: []group.MemberIndex{3}, + } + + var captured *signingAttemptParams + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := retryLoop.start( + ctx, + func(context.Context, uint64) error { return nil }, + func() (uint64, error) { return 200, nil }, + func(params *signingAttemptParams) (*signing.Result, uint64, error) { + captured = params + return testResult, 215, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured == nil { + t.Fatal("the committed signing attempt was never executed") + } + if len(captured.transientlyParkedMembersIndexes) != 1 || + captured.transientlyParkedMembersIndexes[0] != 3 { + t.Fatalf( + "active attempt must carry the parked set [3]; got %v", + captured.transientlyParkedMembersIndexes, + ) + } +} + +// TestSigningRetryLoop_ActiveAttemptUsesAttemptCounterWhenRoastInactive asserts +// the legacy invariant is preserved: with ROAST retry inactive (no coordinator +// registered), the active attempt keys off the block-paced attemptCounter +// unchanged, even after a skip. +func TestSigningRetryLoop_ActiveAttemptUsesAttemptCounterWhenRoastInactive(t *testing.T) { + // Readiness opted in but NO coordinator registered -> RoastRetryActive is false. + t.Setenv(signing.RoastRetryReadinessOptInEnvVar, "true") + signing.ResetRoastRetryRegistrationForTest() + t.Cleanup(signing.ResetRoastRetryRegistrationForTest) + if signing.RoastRetryActive() { + t.Fatal("precondition: ROAST retry must be inactive without a coordinator") + } + + message := big.NewInt(100) + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(sessionID string) ([]group.MemberIndex, error) { + if sessionID == fmt.Sprintf("%v-%v", message, 1) { + return []group.MemberIndex{1, 2}, nil + } + return []group.MemberIndex{1, 2, 3}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + } + retryLoop := newSigningRetryLoop( + &testutils.MockLogger{}, + message, + "", + 200, + 1, + chain.Addresses{"address-1", "address-2", "address-3"}, + &GroupParameters{GroupSize: 3, HonestThreshold: 3}, + announcer, + doneCheck, + ) + + var captured *signingAttemptParams + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := retryLoop.start( + ctx, + func(context.Context, uint64) error { return nil }, + func() (uint64, error) { return 200, nil }, + func(params *signingAttemptParams) (*signing.Result, uint64, error) { + captured = params + return testResult, 215, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured == nil { + t.Fatal("the committed signing attempt was never executed") + } + // Legacy: the active attempt keys off attemptCounter (2 after one skip). + if captured.number != 2 { + t.Fatalf( + "with ROAST inactive the active attempt must use the block-paced "+ + "attemptCounter (2); got %d", + captured.number, + ) + } +} diff --git a/pkg/tbtc/signing_loop_legacy_selector.go b/pkg/tbtc/signing_loop_legacy_selector.go new file mode 100644 index 0000000000..94cd7c6a1e --- /dev/null +++ b/pkg/tbtc/signing_loop_legacy_selector.go @@ -0,0 +1,119 @@ +package tbtc + +import ( + "fmt" + "math/rand" + "sort" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/retry" +) + +// legacySigningParticipantSelector is the pre-RFC-21 implementation: +// it calls the pseudo-random retry shuffle in pkg/protocol/retry and maps +// the resulting qualified operators back to the included member +// indices. +// +// Kept as the canonical fallback through Phase 6; Phase 7 may +// remove it once the ROAST-driven retry path is fully wired and +// the readiness manifest flips. +// +// The legacy code is *intentionally retained* through Phase 6 to +// preserve the operational rollback path: if a deployment toggles +// the readiness env var off, this implementation is what the +// dispatcher falls back to. +// +// RFC-21 Phase 7.3 PR2b-1a moved the member-index mapping and the +// surplus trim from the signing-loop INTO this selector so selection +// is member-level (see signingParticipantSelector). The computation is +// byte-identical to the pre-RFC-21 signing_loop.excludedMembersIndexes: +// the same operator selection, the same per-member inclusion rule, and +// the same seeded surplus shuffle (seed offset by the attempt counter, +// which is retryCount+1), just producing the included set directly +// instead of the excluded complement. +type legacySigningParticipantSelector struct{} + +func (legacySigningParticipantSelector) Select( + readyMembersIndexes []group.MemberIndex, + signingGroupOperators chain.Addresses, + seed int64, + retryCount uint, + _ uint, // roastAttemptNumber: legacy diversifies by retryCount, not the ROAST counter + honestThreshold uint, + _ string, + _ group.MemberIndex, +) (participantSelection, error) { + // Build the input the retry shuffle expects: one operator address + // per ready member (an operator controlling k ready members appears + // k times, matching the pre-RFC-21 input to the algorithm). + readyOperators := make([]chain.Address, 0, len(readyMembersIndexes)) + for _, memberIndex := range readyMembersIndexes { + readyOperators = append( + readyOperators, + signingGroupOperators[memberIndex-1], + ) + } + + qualifiedOperators, err := retry.EvaluateRetryParticipantsForSigning( + readyOperators, + seed, + retryCount, + honestThreshold, + ) + if err != nil { + return participantSelection{}, fmt.Errorf( + "legacy participant selector: random operator selection failed: %w", + err, + ) + } + qualifiedOperatorsSet := chain.Addresses(qualifiedOperators).Set() + + readyMembersSet := make(map[group.MemberIndex]bool, len(readyMembersIndexes)) + for _, memberIndex := range readyMembersIndexes { + readyMembersSet[memberIndex] = true + } + + // Include a member iff its operator is qualified and the member + // itself announced readiness. This is the per-member inclusion rule + // the old signing_loop.excludedMembersIndexes applied; doing it here + // keeps the ROAST-excluded seat of a multi-seat qualified operator + // from being pulled back in (the precision the address-based path + // lost). + includedMembersIndexes := make([]group.MemberIndex, 0, len(signingGroupOperators)) + for i, operator := range signingGroupOperators { + memberIndex := group.MemberIndex(i + 1) + if qualifiedOperatorsSet[operator] && readyMembersSet[memberIndex] { + includedMembersIndexes = append(includedMembersIndexes, memberIndex) + } + } + + // Trim to the smallest required count of signing members for + // performance, dropping the surplus by a seeded shuffle. The shuffle + // seed matches the pre-RFC-21 loop: seed + attemptCounter, and + // attemptCounter == retryCount + 1. + if len(includedMembersIndexes) > int(honestThreshold) { + // #nosec G404 (insecure random number source (rand)) + // Shuffling does not require secure randomness. + rng := rand.New(rand.NewSource(seed + int64(retryCount) + 1)) + // Sort in ascending order first so the shuffle (and thus the + // retained set) is independent of the iteration order above. + sort.Slice(includedMembersIndexes, func(i, j int) bool { + return includedMembersIndexes[i] < includedMembersIndexes[j] + }) + rng.Shuffle(len(includedMembersIndexes), func(i, j int) { + includedMembersIndexes[i], includedMembersIndexes[j] = + includedMembersIndexes[j], includedMembersIndexes[i] + }) + includedMembersIndexes = includedMembersIndexes[:honestThreshold] + } + + // Return the included set in canonical ascending order (matches the + // pre-RFC-21 AttemptInfo.IncludedMembersIndexes, which was the + // ascending complement of the sorted excluded set). + sort.Slice(includedMembersIndexes, func(i, j int) bool { + return includedMembersIndexes[i] < includedMembersIndexes[j] + }) + // The legacy path never parks: parking is a ROAST-transition concept. + return participantSelection{includedMembersIndexes: includedMembersIndexes}, nil +} diff --git a/pkg/tbtc/signing_loop_roast_dispatcher.go b/pkg/tbtc/signing_loop_roast_dispatcher.go new file mode 100644 index 0000000000..b1b54b6f18 --- /dev/null +++ b/pkg/tbtc/signing_loop_roast_dispatcher.go @@ -0,0 +1,75 @@ +package tbtc + +import ( + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// signingParticipantSelector picks the set of members included in a +// signing attempt. The legacy implementation is the pseudo-random +// retry shuffle in pkg/protocol/retry; the RFC-21 Phase-6 migration +// introduces this interface so an alternate ROAST-driven +// implementation can be installed behind the frost_roast_retry build +// tag without touching the call site. +// +// PR 6.4 ships the dispatcher with only the legacy implementation +// installed; Phase 7 wires the ROAST-driven implementation along +// with the supporting AggregateBundle production at the executor- +// adapter layer. Until the ROAST selector consumes its transition +// record (RFC-21 Phase 7.3 PR2b), behaviour is byte-identical to +// pre-RFC-21 retry semantics. +// +// RFC-21 Phase 7.3 PR2b-1a made selection MEMBER-LEVEL: Select returns +// the exact included member indices, not a qualified-operator address +// set the caller re-maps. The legacy path computes the indices +// internally (operator selection + member mapping + surplus trim); +// the ROAST path (PR2b) returns the transition's IncludedSet directly. +// This removes the multi-seat precision loss of the old address-based +// path, where one ready seat qualified ALL of an operator's seats -- +// including ones ROAST means to park or exclude. +type signingParticipantSelector interface { + // Select returns the participant selection for the given attempt. + // readyMembersIndexes is the set of members whose ready signal was + // received this attempt; signingGroupOperators is the full + // member->operator roster (index i is member i+1), used by the + // legacy path to map qualified operators back to members. seed is + // the per-message retry seed; retryCount is the 0-based LEGACY retry + // counter (the block-paced loop counter, for the legacy shuffle). + // roastAttemptNumber is the 0-based COMMITTED ROAST attempt index + // (advanced only by observed attempts), which the ROAST selector + // keys its freshness/consume off so block-timing skips do not break + // the transition chain. honestThreshold is the group's signing + // threshold. sessionID is the STABLE ROAST session id and + // memberIndex is the local signer's member. + Select( + readyMembersIndexes []group.MemberIndex, + signingGroupOperators chain.Addresses, + seed int64, + retryCount uint, + roastAttemptNumber uint, + honestThreshold uint, + sessionID string, + memberIndex group.MemberIndex, + ) (participantSelection, error) +} + +// participantSelection is one attempt's selection result: the member-level +// included set, plus the members the prior ROAST transition parked for THIS +// attempt ONLY (empty for the legacy path). The parked set is a subset of the +// complement of the included set; the loop carries it so the attempt after this +// one reinstates them -- without it a one-attempt (transient) park becomes a +// permanent exclusion (RFC-21 Phase 7.3 PR2b-1b). +type participantSelection struct { + includedMembersIndexes []group.MemberIndex + transientlyParkedMembersIndexes []group.MemberIndex +} + +// defaultSigningParticipantSelector returns the build-default +// implementation. Default build: the legacy retry shuffle. Tagged +// build (frost_roast_retry, Phase 7.2): a ROAST-driven selector +// that consults the per-session TransitionMessage registry and +// falls back to the legacy selector when no bundle is available. +// +// Defined in build-tagged sibling files +// (signing_loop_selector_*.go) so the right implementation is +// chosen at compile time without runtime branching. diff --git a/pkg/tbtc/signing_loop_roast_dispatcher_test.go b/pkg/tbtc/signing_loop_roast_dispatcher_test.go new file mode 100644 index 0000000000..a6be9ecbfb --- /dev/null +++ b/pkg/tbtc/signing_loop_roast_dispatcher_test.go @@ -0,0 +1,158 @@ +package tbtc + +import ( + "errors" + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// Note: TestDefaultSigningParticipantSelector_IsLegacy below is +// build-tag-conditional (see _default_build_test.go); under +// frost_roast_retry the default is the ROAST selector and a +// dedicated test verifies that. + +// recordingSelector counts how often Select was called and returns +// a fixed result. Tests use it to assert the dispatcher routes +// participant selection through the configured selector rather +// than the legacy path. +type recordingSelector struct { + calls int + result []group.MemberIndex + err error +} + +func (r *recordingSelector) Select( + readyMembersIndexes []group.MemberIndex, + _ chain.Addresses, + _ int64, + _ uint, + _ uint, + _ uint, + _ string, + _ group.MemberIndex, +) (participantSelection, error) { + r.calls++ + if r.err != nil { + return participantSelection{}, r.err + } + included := r.result + if included == nil { + included = readyMembersIndexes + } + return participantSelection{includedMembersIndexes: included}, nil +} + +func TestLegacySigningParticipantSelector_DelegatesToRetryShuffle(t *testing.T) { + operators := chain.Addresses{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + chain.Address("op-4"), + chain.Address("op-5"), + } + readyMembers := []group.MemberIndex{1, 2, 3, 4, 5} + sel := legacySigningParticipantSelector{} + // Args: ready, operators, seed, retryCount, roastAttemptNumber, honestThreshold, + // sessionID, memberIndex. + selection, err := sel.Select(readyMembers, operators, 42, 0, 0, 3, "session-x", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := selection.includedMembersIndexes + // Five members are ready and the honest threshold is 3, so the + // surplus trim leaves exactly the threshold count included. + if len(got) != 3 { + t.Fatalf("expected 3 included members (the honest threshold), got %d", len(got)) + } + // The legacy path never parks. + if len(selection.transientlyParkedMembersIndexes) != 0 { + t.Fatalf("legacy selection must not park members, got %v", selection.transientlyParkedMembersIndexes) + } + // The result must be sorted ascending and contain only ready members. + for i := 1; i < len(got); i++ { + if got[i] <= got[i-1] { + t.Fatalf("expected included members sorted ascending, got %v", got) + } + } +} + +func TestLegacySigningParticipantSelector_PropagatesErrors(t *testing.T) { + sel := legacySigningParticipantSelector{} + _, err := sel.Select( + []group.MemberIndex{1}, + chain.Addresses{chain.Address("op-1")}, + 0, 0, 0, + 99, // honest threshold higher than member count + "session-x", + 1, + ) + if err == nil { + t.Fatal("expected error from retry shuffle") + } +} + +func TestSigningRetryLoopUsesDispatcher(t *testing.T) { + sentinel := []group.MemberIndex{1, 2, 3} + recorder := &recordingSelector{result: sentinel} + + srl := &signingRetryLoop{ + signingGroupOperators: chain.Addresses{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + chain.Address("op-4"), + chain.Address("op-5"), + }, + groupParameters: &GroupParameters{ + HonestThreshold: 3, + }, + attemptCounter: 1, + attemptSeed: 42, + participantSelector: recorder, + } + + included, excluded, _, err := srl.performMembersSelection( + []group.MemberIndex{1, 2, 3, 4, 5}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if recorder.calls != 1 { + t.Fatalf("expected dispatcher to be called once; got %d", recorder.calls) + } + if !reflect.DeepEqual(included, sentinel) { + t.Fatalf( + "expected the dispatcher's included set (the sentinel %v), got %v", + sentinel, included, + ) + } + // Excluded is the complement of the sentinel over the 5-member group. + wantExcluded := []group.MemberIndex{4, 5} + if !reflect.DeepEqual(excluded, wantExcluded) { + t.Fatalf("expected excluded %v, got %v", wantExcluded, excluded) + } +} + +func TestSigningRetryLoopPropagatesSelectorError(t *testing.T) { + wantErr := errors.New("synthetic selector failure") + srl := &signingRetryLoop{ + signingGroupOperators: chain.Addresses{ + chain.Address("op-1"), + chain.Address("op-2"), + }, + groupParameters: &GroupParameters{HonestThreshold: 2}, + attemptCounter: 1, + attemptSeed: 0, + participantSelector: &recordingSelector{err: wantErr}, + } + _, _, _, err := srl.performMembersSelection([]group.MemberIndex{1, 2}) + if err == nil { + t.Fatal("expected selector error to propagate") + } + if !errors.Is(err, wantErr) { + t.Fatalf("expected wrapped sentinel; got %v", err) + } +} diff --git a/pkg/tbtc/signing_loop_roast_transition_controller.go b/pkg/tbtc/signing_loop_roast_transition_controller.go new file mode 100644 index 0000000000..12f1824a39 --- /dev/null +++ b/pkg/tbtc/signing_loop_roast_transition_controller.go @@ -0,0 +1,55 @@ +package tbtc + +import "github.com/keep-network/keep-core/pkg/protocol/group" + +// roastTransitionController owns the session-scoped ROAST transition machinery +// for one local signer across a signing's attempts (RFC-21 Phase 7.3 PR2b-1b). +// +// The signing retry loop holds one per signer. A nil controller -- the default +// build, or a deployment that does not drive ROAST retry -- makes the loop skip +// every transition step, so behaviour is the legacy retry shuffle. +// +// PR2b-1b lands in three commits: +// - C1 (this commit) wires the observe step: every attempt, every local seat +// (including ones it is excluded from) records a local handle binding so it +// can later verify a transition bundle and run NextAttempt. INERT -- the +// bindings are produced but not consumed yet. +// - C2 adds the failed-attempt transition exchange (forced snapshots -> +// coordinator aggregation -> bundle distribution). +// - C3 adds member-level consumption + fail-closed selection (the activation). +type roastTransitionController interface { + // BeginObservedAttempt records a local observe binding for the attempt so the + // signer can later verify the attempt's transition bundle and compute the next + // attempt's included set. Called for EVERY attempt, including ones this signer + // is excluded from (an excluded seat may be reinstated by NextAttempt, so it + // must track the transition too). Best-effort: a failure is logged, never + // propagated to the signing flow. + BeginObservedAttempt( + roastAttemptNumber uint, + includedMembersIndexes []group.MemberIndex, + excludedMembersIndexes []group.MemberIndex, + transientlyParkedMembersIndexes []group.MemberIndex, + ) + // OnAttemptFailed signals that a committed attempt this seat participated in + // failed, so the transition exchange should run: publish this seat's forced + // proof-of-attendance snapshot and, on the elected coordinator, aggregate + + // broadcast the transition bundle once the snapshot collection window + // (derived from timeoutBlock) closes. Best-effort and non-blocking: the + // aggregation runs off the retry-loop goroutine. + OnAttemptFailed(attemptNumber uint, timeoutBlock uint64) + // OnAttemptSucceeded signals that a committed attempt this seat participated in + // completed successfully (a valid signature aggregated locally). It clears this + // seat's observe binding for the attempt so neither an elected coordinator's + // aggregation nor a peer's failure bundle can synthesize or store a failure + // transition for an attempt that actually succeeded; a subsequent done-check + // failure then fails closed (no fresh record) instead of consuming a dishonest + // failure transition. Best-effort. + OnAttemptSucceeded() + // HasLostSync reports whether this seat fell behind the group's committed ROAST + // attempt chain -- it received a transition bundle for an attempt it never + // observed (it skipped a window peers committed). The retry loop checks it + // before selection and fails closed when true, since selecting from a stale + // position diverges from peers (the fracture class). Always false for a nil + // controller or inactive ROAST. + HasLostSync() bool +} diff --git a/pkg/tbtc/signing_loop_roast_transition_controller_default.go b/pkg/tbtc/signing_loop_roast_transition_controller_default.go new file mode 100644 index 0000000000..027e92f855 --- /dev/null +++ b/pkg/tbtc/signing_loop_roast_transition_controller_default.go @@ -0,0 +1,24 @@ +//go:build !(frost_native && frost_roast_retry) + +package tbtc + +import ( + "context" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost/signing" +) + +// newRoastTransitionController returns nil in builds without both the +// frost_native and frost_roast_retry tags: there is no ROAST transition +// machinery to drive, so the signing loop skips every transition step and uses +// the legacy retry shuffle. The nil controller is the same signal the active +// build uses for a nil request template. +func newRoastTransitionController( + _ context.Context, + _ log.StandardLogger, + _ *signing.Request, + _ waitForBlockFn, +) roastTransitionController { + return nil +} diff --git a/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry.go b/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry.go new file mode 100644 index 0000000000..c0c2380974 --- /dev/null +++ b/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry.go @@ -0,0 +1,204 @@ +//go:build frost_native && frost_roast_retry + +package tbtc + +import ( + "context" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// roastTransitionExchange is the produce-side surface the controller drives on a +// failed attempt. The production implementation is *signing.RoastTransitionExchange; +// the controller tests inject a fake. +type roastTransitionExchange interface { + BroadcastForcedSnapshot(attemptHash [32]byte) + AggregateAndBroadcast(attemptHash [32]byte) + HasLostSync() bool +} + +// roastTransitionControllerImpl is the active (frost_native && frost_roast_retry) +// transition controller. Constructed once per local signer, it observes each +// attempt (C1) and, on a failed attempt, drives the transition exchange (C2): it +// publishes this seat's forced proof-of-attendance snapshot and, after the +// collection window, has the elected coordinator aggregate + broadcast the +// bundle. +// +// BeginObservedAttempt and OnAttemptFailed are called only from the signer's +// single retry-loop goroutine, sequentially within an attempt, so currentAttemptHash +// needs no lock; the deadline goroutine captures the hash by value. +type roastTransitionControllerImpl struct { + ctx context.Context + logger log.StandardLogger + requestTemplate *signing.Request + waitForBlockFn waitForBlockFn + // exchange is nil when ROAST retry is inactive (readiness opted out, no + // coordinator, or no usable channel); the controller then observes only. + exchange roastTransitionExchange + + currentAttemptHash [32]byte +} + +// newRoastTransitionController builds the active controller. requestTemplate +// carries the static (non-per-attempt) request material; a nil template yields a +// nil controller (the loop then skips transition steps). +func newRoastTransitionController( + ctx context.Context, + logger log.StandardLogger, + requestTemplate *signing.Request, + waitForBlockFn waitForBlockFn, +) roastTransitionController { + if requestTemplate == nil { + return nil + } + if logger == nil { + logger = log.Logger("keep-tbtc-roast-transition") + } + return &roastTransitionControllerImpl{ + ctx: ctx, + logger: logger, + requestTemplate: requestTemplate, + waitForBlockFn: waitForBlockFn, + exchange: newRoastTransitionExchangeForRequest(ctx, logger, requestTemplate), + } +} + +// newRoastTransitionExchangeForRequest builds the session-scoped transition +// exchange when ROAST retry is active: readiness opt-in on, a coordinator +// registered, and a usable wallet channel. It returns nil otherwise, so the +// controller observes only (C1) and drives no exchange -- the same set of +// deterministic static conditions ObserveAttemptForTransition gates on. +func newRoastTransitionExchangeForRequest( + ctx context.Context, + logger log.StandardLogger, + template *signing.Request, +) roastTransitionExchange { + // RFC-21 Phase 7.3 PR2b-1.5: gate + fetch deps for THIS seat, so a multi-seat + // operator's exchange uses the coordinator bound to template.MemberIndex (the + // elected-but-not-process-default seat can then collect + aggregate). + if !signing.RoastRetryActiveForMember(template.MemberIndex) { + return nil + } + deps, ok := signing.RegisteredRoastRetryCoordinatorForMember(template.MemberIndex) + if !ok || deps.Coordinator == nil { + return nil + } + if template.Channel == nil || template.MembershipValidator == nil { + return nil + } + bus, err := signing.NewBroadcastChannelRunnerBus( + ctx, logger, template.Channel, template.MembershipValidator, + ) + if err != nil { + logger.Warnf("roast transition: build transport bus: [%v]", err) + return nil + } + return signing.NewRoastTransitionExchange( + ctx, logger, bus, deps, template.RoastSessionID, template.MemberIndex, + ) +} + +func (c *roastTransitionControllerImpl) BeginObservedAttempt( + roastAttemptNumber uint, + includedMembersIndexes []group.MemberIndex, + excludedMembersIndexes []group.MemberIndex, + transientlyParkedMembersIndexes []group.MemberIndex, +) { + // Shallow-copy the static template and stamp this attempt's metadata. + // Attempt.Number is 1-based; BuildAttemptContextFromRequest maps it to the + // 0-based AttemptContext.AttemptNumber == roastAttemptNumber, so the observe + // context and the transition-record freshness chain key off the committed + // ROAST attempt index, not the block-paced loop counter. + request := *c.requestTemplate + request.Attempt = &signing.Attempt{ + Number: roastAttemptNumber + 1, + IncludedMembersIndexes: includedMembersIndexes, + ExcludedMembersIndexes: excludedMembersIndexes, + TransientlyParkedMembersIndexes: transientlyParkedMembersIndexes, + } + + hash, err := signing.ObserveAttemptForTransition(&request) + if err != nil { + c.logger.Warnf( + "[member:%v] roast transition: observe roast attempt [%v] failed: [%v]", + request.MemberIndex, roastAttemptNumber, err, + ) + } + // Retain the attempt hash so OnAttemptFailed can drive the exchange against + // this attempt's observe binding. Zero on a static-fallback observe, which + // makes OnAttemptFailed a no-op. + c.currentAttemptHash = hash +} + +func (c *roastTransitionControllerImpl) OnAttemptFailed( + attemptNumber uint, + timeoutBlock uint64, +) { + if c.exchange == nil { + return + } + hash := c.currentAttemptHash + if hash == ([32]byte{}) { + // No observe binding for this attempt (static fallback) -- nothing to do. + return + } + + // The caller reaches the failed-attempt path only when it participated (a + // skipped seat never runs the attempt), so publish its forced + // proof-of-attendance snapshot. + c.exchange.BroadcastForcedSnapshot(hash) + + // The elected coordinator aggregates + broadcasts the bundle once the snapshot + // collection window closes. Run it OFF the retry-loop goroutine so the loop + // proceeds on its own block schedule; AggregateAndBroadcast is a no-op on a + // seat that is not the elected coordinator. snapshotDeadline aligns with the + // cooldown between attempts, so a produced bundle reaches peers before the next + // attempt's selection. + snapshotDeadline := timeoutBlock + signingAttemptCoolDownBlocks + ctx := c.ctx + waitForBlock := c.waitForBlockFn + exchange := c.exchange + go func() { + if waitForBlock != nil { + if err := waitForBlock(ctx, snapshotDeadline); err != nil { + return + } + } + exchange.AggregateAndBroadcast(hash) + }() +} + +func (c *roastTransitionControllerImpl) OnAttemptSucceeded() { + hash := c.currentAttemptHash + if hash == ([32]byte{}) { + // No observe binding for this attempt (static fallback) -- nothing to clear. + return + } + // Clear the observe binding for the succeeded attempt so the observe handle no + // longer collects: the elected coordinator cannot aggregate a failure bundle, + // and a peer's failure bundle is not stored, for an attempt this seat won. The + // observed-history marker remains, so a late bundle for it is a benign + // retransmit rather than lost sync. + signing.ClearObservedAttemptOnLocalSuccess( + c.requestTemplate.RoastSessionID, + c.requestTemplate.MemberIndex, + hash, + ) + // Also drop any coarse-path evidence stashed for this attempt: a succeeded + // attempt never reaches OnAttemptFailed -> BroadcastForcedSnapshot (which is + // what consumes the stash), so without this the entry would leak until the TTL + // sweep (RFC-21 Phase 7.3 PR2b-2 step 2, the blame bridge). + signing.ClearPendingEvidenceOnLocalSuccess( + c.requestTemplate.RoastSessionID, + c.requestTemplate.MemberIndex, + hash, + ) +} + +func (c *roastTransitionControllerImpl) HasLostSync() bool { + // nil when ROAST retry is inactive (the controller only observes): never lost + // sync, since no listener runs to receive a peer's transition. + return c.exchange != nil && c.exchange.HasLostSync() +} diff --git a/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry_test.go b/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry_test.go new file mode 100644 index 0000000000..9fb12c9f42 --- /dev/null +++ b/pkg/tbtc/signing_loop_roast_transition_controller_frost_native_roast_retry_test.go @@ -0,0 +1,193 @@ +//go:build frost_native && frost_roast_retry + +package tbtc + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/frost/signing" +) + +// fakeExchange records the produce-side calls the controller drives. +type fakeExchange struct { + mu sync.Mutex + broadcastCalls [][32]byte + aggregateCalls [][32]byte + aggregated chan struct{} + lostSync bool +} + +func (f *fakeExchange) BroadcastForcedSnapshot(hash [32]byte) { + f.mu.Lock() + f.broadcastCalls = append(f.broadcastCalls, hash) + f.mu.Unlock() +} + +func (f *fakeExchange) AggregateAndBroadcast(hash [32]byte) { + f.mu.Lock() + f.aggregateCalls = append(f.aggregateCalls, hash) + f.mu.Unlock() + if f.aggregated != nil { + f.aggregated <- struct{}{} + } +} + +func (f *fakeExchange) HasLostSync() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.lostSync +} + +func (f *fakeExchange) broadcasts() [][32]byte { + f.mu.Lock() + defer f.mu.Unlock() + return append([][32]byte(nil), f.broadcastCalls...) +} + +func (f *fakeExchange) aggregates() [][32]byte { + f.mu.Lock() + defer f.mu.Unlock() + return append([][32]byte(nil), f.aggregateCalls...) +} + +// TestRoastTransitionController_OnAttemptFailedDrivesExchange asserts a failed +// attempt synchronously broadcasts the forced snapshot and, after the snapshot +// deadline, aggregates + broadcasts the bundle -- both for the attempt's hash. +func TestRoastTransitionController_OnAttemptFailedDrivesExchange(t *testing.T) { + hash := [32]byte{0x01, 0x02, 0x03} + exchange := &fakeExchange{aggregated: make(chan struct{}, 1)} + + deadlineReached := make(chan uint64, 1) + controller := &roastTransitionControllerImpl{ + ctx: context.Background(), + logger: &testutils.MockLogger{}, + exchange: exchange, + waitForBlockFn: func(_ context.Context, block uint64) error { + deadlineReached <- block + return nil + }, + currentAttemptHash: hash, + } + + controller.OnAttemptFailed(1, 100) + + // The forced snapshot broadcast is synchronous. + if got := exchange.broadcasts(); len(got) != 1 || got[0] != hash { + t.Fatalf("expected one forced-snapshot broadcast for the attempt hash, got %v", got) + } + + // The deadline goroutine waits on the snapshot deadline (timeout + cooldown), + // then aggregates. + select { + case block := <-deadlineReached: + if block != 100+signingAttemptCoolDownBlocks { + t.Fatalf( + "expected aggregation to wait until snapshot deadline %d, got %d", + 100+signingAttemptCoolDownBlocks, block, + ) + } + case <-time.After(2 * time.Second): + t.Fatal("expected the deadline goroutine to wait for a block") + } + + select { + case <-exchange.aggregated: + case <-time.After(2 * time.Second): + t.Fatal("expected AggregateAndBroadcast to run after the deadline") + } + if got := exchange.aggregates(); len(got) != 1 || got[0] != hash { + t.Fatalf("expected one aggregate for the attempt hash, got %v", got) + } +} + +// TestRoastTransitionController_OnAttemptFailedNoExchangeIsSafe asserts a +// controller with no exchange (ROAST retry inactive) ignores a failed attempt. +func TestRoastTransitionController_OnAttemptFailedNoExchangeIsSafe(t *testing.T) { + controller := &roastTransitionControllerImpl{ + ctx: context.Background(), + logger: &testutils.MockLogger{}, + currentAttemptHash: [32]byte{0x01}, + } + controller.OnAttemptFailed(1, 100) // must not panic +} + +// TestRoastTransitionController_OnAttemptFailedZeroHashIsNoOp asserts that +// without a stored attempt hash (a static-fallback observe) the exchange is not +// driven. +func TestRoastTransitionController_OnAttemptFailedZeroHashIsNoOp(t *testing.T) { + exchange := &fakeExchange{aggregated: make(chan struct{}, 1)} + controller := &roastTransitionControllerImpl{ + ctx: context.Background(), + logger: &testutils.MockLogger{}, + exchange: exchange, + waitForBlockFn: func(context.Context, uint64) error { return nil }, + // currentAttemptHash is the zero value. + } + controller.OnAttemptFailed(1, 100) + + if got := exchange.broadcasts(); len(got) != 0 { + t.Fatalf("zero attempt hash must not broadcast a snapshot, got %v", got) + } + // Give any erroneously-spawned goroutine a chance to fire. + select { + case <-exchange.aggregated: + t.Fatal("zero attempt hash must not aggregate") + case <-time.After(100 * time.Millisecond): + } +} + +// TestRoastTransitionController_HasLostSyncDelegatesToExchange asserts HasLostSync +// reflects the exchange's lost-sync state, and is false when no exchange is +// installed (ROAST retry inactive -> observe-only -> no listener -> never lost +// sync). +func TestRoastTransitionController_HasLostSyncDelegatesToExchange(t *testing.T) { + exchange := &fakeExchange{} + controller := &roastTransitionControllerImpl{ + ctx: context.Background(), + logger: &testutils.MockLogger{}, + exchange: exchange, + } + if controller.HasLostSync() { + t.Fatal("expected not lost sync initially") + } + exchange.mu.Lock() + exchange.lostSync = true + exchange.mu.Unlock() + if !controller.HasLostSync() { + t.Fatal("expected HasLostSync to reflect the exchange's lost-sync state") + } + + noExchange := &roastTransitionControllerImpl{ + ctx: context.Background(), + logger: &testutils.MockLogger{}, + } + if noExchange.HasLostSync() { + t.Fatal("a controller without an exchange must never report lost sync") + } +} + +// TestRoastTransitionController_OnAttemptSucceededZeroHashIsNoOp asserts that +// without a stored attempt hash (a static-fallback observe) the success hook +// clears nothing. +func TestRoastTransitionController_OnAttemptSucceededZeroHashIsNoOp(t *testing.T) { + signing.ResetObservedAttemptRegistryForTest() + t.Cleanup(signing.ResetObservedAttemptRegistryForTest) + + controller := &roastTransitionControllerImpl{ + ctx: context.Background(), + logger: &testutils.MockLogger{}, + requestTemplate: &signing.Request{ + RoastSessionID: "ctrl-success-session", + MemberIndex: 1, + }, + // currentAttemptHash is the zero value. + } + controller.OnAttemptSucceeded() // must not panic + if signing.ObservedAttemptStoredForTest("ctrl-success-session", 1) { + t.Fatal("zero attempt hash must not interact with the registry") + } +} diff --git a/pkg/tbtc/signing_loop_roast_transition_controller_test.go b/pkg/tbtc/signing_loop_roast_transition_controller_test.go new file mode 100644 index 0000000000..94d54e315f --- /dev/null +++ b/pkg/tbtc/signing_loop_roast_transition_controller_test.go @@ -0,0 +1,468 @@ +package tbtc + +import ( + "context" + "errors" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type observedAttemptCall struct { + roastAttemptNumber uint + included []group.MemberIndex + excluded []group.MemberIndex + parked []group.MemberIndex +} + +type failedAttemptCall struct { + attemptNumber uint + timeoutBlock uint64 +} + +// fakeTransitionController records controller calls so the loop-wiring tests can +// assert the loop observes each attempt (with the exact member sets), signals +// failed and succeeded attempts, and consults lost-sync before selection. +type fakeTransitionController struct { + calls []observedAttemptCall + failedCalls []failedAttemptCall + succeededCalls int + // lostSync is returned by HasLostSync; a test sets it to drive the loop's + // fail-closed-before-selection path. + lostSync bool +} + +func (f *fakeTransitionController) BeginObservedAttempt( + roastAttemptNumber uint, + includedMembersIndexes []group.MemberIndex, + excludedMembersIndexes []group.MemberIndex, + transientlyParkedMembersIndexes []group.MemberIndex, +) { + f.calls = append(f.calls, observedAttemptCall{ + roastAttemptNumber: roastAttemptNumber, + included: includedMembersIndexes, + excluded: excludedMembersIndexes, + parked: transientlyParkedMembersIndexes, + }) +} + +func (f *fakeTransitionController) OnAttemptFailed( + attemptNumber uint, + timeoutBlock uint64, +) { + f.failedCalls = append(f.failedCalls, failedAttemptCall{ + attemptNumber: attemptNumber, + timeoutBlock: timeoutBlock, + }) +} + +func (f *fakeTransitionController) OnAttemptSucceeded() { + f.succeededCalls++ +} + +func (f *fakeTransitionController) HasLostSync() bool { + return f.lostSync +} + +func newObserveTestRetryLoop( + announcer signingAnnouncer, + doneCheck signingDoneCheckStrategy, +) *signingRetryLoop { + return newSigningRetryLoop( + &testutils.MockLogger{}, + big.NewInt(100), + "", // roastSessionID + 200, // initialStartBlock + 1, // signingGroupMemberIndex + chain.Addresses{ + "address-1", + "address-2", + "address-3", + "address-4", + "address-5", + }, + &GroupParameters{GroupSize: 5, HonestThreshold: 3}, + announcer, + doneCheck, + ) +} + +func runOneSuccessfulAttempt( + t *testing.T, + retryLoop *signingRetryLoop, +) { + t.Helper() + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := retryLoop.start( + ctx, + func(context.Context, uint64) error { return nil }, + func() (uint64, error) { return 200, nil }, + func(*signingAttemptParams) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestSigningRetryLoop_ObservesEachAttempt asserts the loop drives the +// transition controller's observe step once per attempt, with the exact +// member-level included/excluded sets selection produced -- the binding the +// later commits' transition exchange and selection consume. +func TestSigningRetryLoop_ObservesEachAttempt(t *testing.T) { + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(string) ([]group.MemberIndex, error) { + return []group.MemberIndex{1, 2, 3, 4, 5}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + } + + retryLoop := newObserveTestRetryLoop(announcer, doneCheck) + controller := &fakeTransitionController{} + retryLoop.setTransitionController(controller) + + runOneSuccessfulAttempt(t, retryLoop) + + if len(controller.calls) != 1 { + t.Fatalf("expected BeginObservedAttempt called once, got %d", len(controller.calls)) + } + call := controller.calls[0] + if call.roastAttemptNumber != 0 { + t.Fatalf("expected the first committed roast attempt number 0, got %d", call.roastAttemptNumber) + } + // The observed sets must partition the whole group and match the honest + // threshold count -- i.e. the loop passes selection's exact member-level + // output, not a recomputed approximation. + if len(call.included) != 3 { + t.Fatalf("expected 3 included members (the honest threshold), got %v", call.included) + } + if len(call.included)+len(call.excluded) != 5 { + t.Fatalf( + "included+excluded must cover the group of 5, got %d+%d", + len(call.included), len(call.excluded), + ) + } +} + +// TestSigningRetryLoop_NilTransitionControllerIsSafe asserts a loop with no +// controller installed (the default build / non-ROAST deployment) runs an +// attempt without panicking. +func TestSigningRetryLoop_NilTransitionControllerIsSafe(t *testing.T) { + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(string) ([]group.MemberIndex, error) { + return []group.MemberIndex{1, 2, 3, 4, 5}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + } + + retryLoop := newObserveTestRetryLoop(announcer, doneCheck) + // No setTransitionController -> transitionController stays nil. + runOneSuccessfulAttempt(t, retryLoop) +} + +// TestSigningRetryLoop_SignalsFailedAttempt asserts the loop signals +// OnAttemptFailed (with the attempt's timeout block) when a committed attempt +// fails, and only for the failed attempt. A 3-of-3 group keeps the local seat +// included on every attempt, so it reaches the committed-failure path. +func TestSigningRetryLoop_SignalsFailedAttempt(t *testing.T) { + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(string) ([]group.MemberIndex, error) { + return []group.MemberIndex{1, 2, 3}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + } + retryLoop := newSigningRetryLoop( + &testutils.MockLogger{}, + big.NewInt(100), + "", + 200, + 1, + chain.Addresses{"address-1", "address-2", "address-3"}, + &GroupParameters{GroupSize: 3, HonestThreshold: 3}, + announcer, + doneCheck, + ) + controller := &fakeTransitionController{} + retryLoop.setTransitionController(controller) + + attempts := 0 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := retryLoop.start( + ctx, + func(context.Context, uint64) error { return nil }, + func() (uint64, error) { return 200, nil }, + func(*signingAttemptParams) (*signing.Result, uint64, error) { + attempts++ + if attempts == 1 { + return nil, 0, errors.New("synthetic attempt failure") + } + return testResult, 215, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(controller.failedCalls) != 1 { + t.Fatalf("expected OnAttemptFailed called once, got %d", len(controller.failedCalls)) + } + if controller.failedCalls[0].attemptNumber != 1 { + t.Fatalf( + "expected failed attempt number 1, got %d", + controller.failedCalls[0].attemptNumber, + ) + } + // Every attempt that reaches selection is observed (the failed one and the + // successful retry). + if len(controller.calls) != 2 { + t.Fatalf("expected BeginObservedAttempt called twice, got %d", len(controller.calls)) + } + // The committed ROAST attempt number advances 0 -> 1 across the two committed + // attempts (no pre-selection skip here), so the transition chain is consecutive. + if controller.calls[0].roastAttemptNumber != 0 || controller.calls[1].roastAttemptNumber != 1 { + t.Fatalf( + "committed roast attempt numbers must advance 0,1; got %d,%d", + controller.calls[0].roastAttemptNumber, controller.calls[1].roastAttemptNumber, + ) + } +} + +// TestSigningRetryLoop_SkipDoesNotAdvanceRoastAttemptNumber asserts the committed +// ROAST attempt number advances only on attempts that reach selection/observe, +// not on the pre-selection skips (here a minority-readiness skip). The committed +// attempt after a skip must still be roast attempt 0 -- otherwise the transition +// chain would expect a record for an attempt that never ran (Codex B2). +func TestSigningRetryLoop_SkipDoesNotAdvanceRoastAttemptNumber(t *testing.T) { + message := big.NewInt(100) + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(sessionID string) ([]group.MemberIndex, error) { + // Loop attempt 1: minority readiness (< honest threshold) -> skipped + // BEFORE selection/observe. Loop attempt 2: full -> committed. + if sessionID == fmt.Sprintf("%v-%v", message, 1) { + return []group.MemberIndex{1, 2}, nil + } + return []group.MemberIndex{1, 2, 3}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + } + retryLoop := newSigningRetryLoop( + &testutils.MockLogger{}, + message, + "", + 200, + 1, + chain.Addresses{"address-1", "address-2", "address-3"}, + &GroupParameters{GroupSize: 3, HonestThreshold: 3}, + announcer, + doneCheck, + ) + controller := &fakeTransitionController{} + retryLoop.setTransitionController(controller) + + runOneSuccessfulAttempt(t, retryLoop) + + // Only the committed (loop attempt 2) attempt was observed; the minority skip + // did not. + if len(controller.calls) != 1 { + t.Fatalf("expected 1 observed (committed) attempt, got %d", len(controller.calls)) + } + if controller.calls[0].roastAttemptNumber != 0 { + t.Fatalf( + "the committed attempt after a skip must be roast attempt 0, got %d", + controller.calls[0].roastAttemptNumber, + ) + } +} + +// TestSigningRetryLoop_LocalSuccessSignalsSucceeded asserts the loop signals +// OnAttemptSucceeded -- and NOT OnAttemptFailed -- when a committed attempt the +// seat participated in completes successfully. The succeeded signal clears the +// observe binding so no failure transition can be synthesized for an attempt this +// seat won (Codex B3). A 3-of-3 group keeps the local seat included. +func TestSigningRetryLoop_LocalSuccessSignalsSucceeded(t *testing.T) { + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(string) ([]group.MemberIndex, error) { + return []group.MemberIndex{1, 2, 3}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + } + retryLoop := newSigningRetryLoop( + &testutils.MockLogger{}, + big.NewInt(100), + "", + 200, + 1, + chain.Addresses{"address-1", "address-2", "address-3"}, + &GroupParameters{GroupSize: 3, HonestThreshold: 3}, + announcer, + doneCheck, + ) + controller := &fakeTransitionController{} + retryLoop.setTransitionController(controller) + + runOneSuccessfulAttempt(t, retryLoop) + + if controller.succeededCalls != 1 { + t.Fatalf("expected OnAttemptSucceeded called once, got %d", controller.succeededCalls) + } + if len(controller.failedCalls) != 0 { + t.Fatalf("a successful attempt must not signal OnAttemptFailed, got %d", len(controller.failedCalls)) + } +} + +// TestSigningRetryLoop_DoneCheckFailureAfterSuccessDoesNotSignalFailure asserts +// that when the protocol round succeeds locally but the done-check then fails, the +// loop signals OnAttemptSucceeded (clearing the observe binding) and NEVER +// OnAttemptFailed -- it must not synthesize a failure transition for an attempt +// whose signature aggregated (Codex B3, adjudicated to mark-succeeded + fail-closed +// over synthesizing a no-reject transition). Here the done-check fails on the first +// attempt and succeeds on the retry; both attempts succeeded locally. +func TestSigningRetryLoop_DoneCheckFailureAfterSuccessDoesNotSignalFailure(t *testing.T) { + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(string) ([]group.MemberIndex, error) { + return []group.MemberIndex{1, 2, 3}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(attemptNumber uint64) (*signing.Result, uint64, error) { + // The protocol round (signingAttemptFn) always succeeds; the done-check + // coordination fails on the first attempt only. + if attemptNumber == 1 { + return nil, 0, errors.New("synthetic done-check failure after local success") + } + return testResult, 215, nil + }, + } + retryLoop := newSigningRetryLoop( + &testutils.MockLogger{}, + big.NewInt(100), + "", + 200, + 1, + chain.Addresses{"address-1", "address-2", "address-3"}, + &GroupParameters{GroupSize: 3, HonestThreshold: 3}, + announcer, + doneCheck, + ) + controller := &fakeTransitionController{} + retryLoop.setTransitionController(controller) + + runOneSuccessfulAttempt(t, retryLoop) + + // Both attempts' protocol rounds succeeded locally, so each signals succeeded. + if controller.succeededCalls != 2 { + t.Fatalf("expected OnAttemptSucceeded for each locally-succeeded attempt (2), got %d", controller.succeededCalls) + } + // A done-check failure after a local success must NOT be reported as an attempt + // failure -- no failure transition is honest for an attempt that aggregated. + if len(controller.failedCalls) != 0 { + t.Fatalf( + "a done-check failure after local success must not signal OnAttemptFailed, got %d", + len(controller.failedCalls), + ) + } +} + +// TestSigningRetryLoop_LostSyncFailsClosedBeforeSelection asserts the loop fails +// closed -- before selection and before observing -- when the controller reports +// lost ROAST sync (its listener received a transition for an attempt this seat +// never observed). Selecting from a stale position would diverge from peers (the +// fracture class), so the loop terminates and the outer layer retries the whole +// signing. +func TestSigningRetryLoop_LostSyncFailsClosedBeforeSelection(t *testing.T) { + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(string) ([]group.MemberIndex, error) { + return []group.MemberIndex{1, 2, 3, 4, 5}, nil + }, + } + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + } + retryLoop := newObserveTestRetryLoop(announcer, doneCheck) + controller := &fakeTransitionController{lostSync: true} + retryLoop.setTransitionController(controller) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := retryLoop.start( + ctx, + func(context.Context, uint64) error { return nil }, + func() (uint64, error) { return 200, nil }, + func(*signingAttemptParams) (*signing.Result, uint64, error) { + return testResult, 215, nil + }, + ) + if err == nil { + t.Fatal("expected a fail-closed error on lost ROAST sync") + } + if !strings.Contains(err.Error(), "lost ROAST sync") { + t.Fatalf("expected a lost-sync fail-closed error, got %v", err) + } + // Fail-closed happens BEFORE selection/observe, so no attempt is observed. + if len(controller.calls) != 0 { + t.Fatalf("lost sync must fail closed before observing any attempt, got %d", len(controller.calls)) + } +} diff --git a/pkg/tbtc/signing_loop_selector_cross_node_fracture_frost_native_roast_retry_test.go b/pkg/tbtc/signing_loop_selector_cross_node_fracture_frost_native_roast_retry_test.go new file mode 100644 index 0000000000..cb2675c2c7 --- /dev/null +++ b/pkg/tbtc/signing_loop_selector_cross_node_fracture_frost_native_roast_retry_test.go @@ -0,0 +1,244 @@ +//go:build frost_native && frost_roast_retry + +package tbtc + +import ( + "errors" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// This test closes the cross-node ROAST/legacy fracture gap: two honest nodes +// with DIVERGENT selection state selecting for the same committed attempt would +// broadcast DIFFERENT NextAttempt included sets (a ROAST-consumed set vs a legacy +// shuffle) -- the fracture class that splits the signing group -- and the +// fail-closed guard (signing_loop_selector_frost_roast_retry.go:83 via +// signing.ConsumeRoastTransitionForSelection) must make BOTH fail closed +// deterministically instead. Prior coverage tested the fail-closed branches in +// isolation with fakes; nothing proved the two configs would ACTUALLY diverge +// and that the guard collapses that divergence to a deterministic fail-closed +// decision on both nodes. +// +// The "two nodes with divergent config" are two seats sharing the process-global +// ROAST registries: node A (the elected coordinator) is registered and ROAST- +// active, node B is an unregistered seat that -- absent the guard -- would fall +// back to the legacy shuffle. The proof is a before/after flip on node B's +// IDENTICAL Select call: with the registry empty it returns a concrete legacy +// set; after node A registers (RoastRetryActive true) the same call FAILS CLOSED +// and does NOT return that set. Node A, whose expected transition is absent for +// the fail-closed session, also fails closed. Neither emits a NextAttempt set, so +// they cannot converge on divergent ones: the network fails closed rather than +// splitting. +// +// Pure-Go: no FROST rounds, bus, or block timing. The whole test is synchronous +// direct calls into the selector and the two package-global registries; the only +// determinism that matters is that the fail-closed decision is a deterministic +// per-seat function of registry state (asserted by repeating each call). + +// fracFixedSigner returns a fixed non-empty signature; the wire encoder rejects +// an empty signature during AggregateBundle and the NoOp verifier accepts this +// one. Defined locally because pkg/frost/signing's fixedSigner is not exported to +// this package. +type fracFixedSigner struct{} + +func (fracFixedSigner) Sign(_ []byte) ([]byte, error) { return []byte{0x01}, nil } + +func fracSignedSnapshot( + member group.MemberIndex, + hash [attempt.MessageDigestLength]byte, +) *roast.LocalEvidenceSnapshot { + snap := roast.NewLocalEvidenceSnapshot(member, hash, attempt.Evidence{}) + payload, _ := snap.SignableBytes() + sig, _ := fracFixedSigner{}.Sign(payload) + snap.OperatorSignature = sig + return snap +} + +func fracSameMembers(a, b []group.MemberIndex) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestROASTSelector_CrossNodeLegacyFractureFailsClosed(t *testing.T) { + t.Setenv(signing.RoastRetryReadinessOptInEnvVar, "true") + signing.ResetRoastRetryRegistrationForTest() + signing.ResetRoastTransitionRegistryForTest() + t.Cleanup(signing.ResetRoastRetryRegistrationForTest) + t.Cleanup(signing.ResetRoastTransitionRegistryForTest) + + roastSel := roastSigningParticipantSelector{} + ready := []group.MemberIndex{1, 2, 3, 4, 5} + ops := selectorTestMembers() + const seed int64 = 42 + const honestThreshold uint = 3 + dkgKey := []byte{0x0a, 0x0b} + var digest [attempt.MessageDigestLength]byte + digest[0] = 0xaa + + // ---- Phase A: prove the two configs produce DIVERGENT NextAttempt sets. ---- + recordedSession := "frac-recorded-session" + prevCtx, err := attempt.NewAttemptContextWithParking( + recordedSession, "frac-key-group", dkgKey, digest, 0, ready, nil, nil, + ) + if err != nil { + t.Fatalf("build prev attempt context: %v", err) + } + hash := prevCtx.Hash() + + // Discover the deterministically elected coordinator for prevCtx. + probe := roast.NewInMemoryCoordinatorWithSigning(0, fracFixedSigner{}, roast.NoOpSignatureVerifier()) + probeHandle, err := probe.BeginAttempt(prevCtx) + if err != nil { + t.Fatalf("probe begin attempt: %v", err) + } + elected, err := probe.SelectedCoordinator(probeHandle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + // Node B is any seat that is NOT elected; it is left UNREGISTERED. + var memberB group.MemberIndex + for _, m := range ready { + if m != elected { + memberB = m + break + } + } + t.Logf("elected(node A)=%d unregistered(node B)=%d", elected, memberB) + + // Register node A's coordinator and build a real, verifiable transition record + // so the ROAST selection path is live for recordedSession. + coordA := roast.NewInMemoryCoordinatorWithSigning(elected, fracFixedSigner{}, roast.NoOpSignatureVerifier()) + registerNodeA := func() { + signing.RegisterRoastRetryCoordinator(signing.RoastRetryDeps{ + Coordinator: coordA, + Signer: fracFixedSigner{}, + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(elected), + }) + } + registerNodeA() + if !signing.RoastRetryActive() { + t.Skip("requires a transition producer (frost_native) for the fail-closed path") + } + handle, err := coordA.BeginAttempt(prevCtx) + if err != nil { + t.Fatalf("node A begin attempt: %v", err) + } + for _, m := range ready { + if err := coordA.RecordEvidence(handle, fracSignedSnapshot(m, hash)); err != nil { + t.Fatalf("record evidence for member %d: %v", m, err) + } + } + bundle, err := coordA.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate bundle: %v", err) + } + signing.RecordRoastTransition(recordedSession, elected, signing.RoastTransitionRecord{ + Bundle: bundle, + PreviousHandle: handle, + PreviousContext: prevCtx, + DkgGroupPublicKey: dkgKey, + }) + + // ROAST-config node: consumes the transition -> the full 5-member set (all + // submitted evidence, none silence-parked). + sRoast, err := roastSel.Select(ready, ops, seed, 1, 1, honestThreshold, recordedSession, elected) + if err != nil { + t.Fatalf("ROAST selection must succeed with a fresh record: %v", err) + } + if len(sRoast.includedMembersIndexes) != len(ready) { + t.Fatalf("expected the full %d-member ROAST set, got %v", len(ready), sRoast.includedMembersIndexes) + } + // legacy-config node: trims to the honest threshold via a seeded shuffle. + sLegacy, err := legacySigningParticipantSelector{}.Select(ready, ops, seed, 1, 1, honestThreshold, recordedSession, elected) + if err != nil { + t.Fatalf("legacy selection failed: %v", err) + } + if len(sLegacy.includedMembersIndexes) != int(honestThreshold) { + t.Fatalf("expected a %d-member legacy set, got %v", honestThreshold, sLegacy.includedMembersIndexes) + } + // FAULT REACHED: the two node configs genuinely select DIFFERENT NextAttempt + // sets -- exactly the fracture the guard exists to prevent. + if fracSameMembers(sRoast.includedMembersIndexes, sLegacy.includedMembersIndexes) { + t.Fatalf("fracture not induced: ROAST and legacy selected the same set %v", sRoast.includedMembersIndexes) + } + t.Logf("divergence induced: ROAST set %v vs legacy set %v", sRoast.includedMembersIndexes, sLegacy.includedMembersIndexes) + + // ---- Phase B: node B WOULD diverge to a concrete legacy set (guard OFF). ---- + freshSess := "frac-fail-closed-session" + signing.ResetRoastRetryRegistrationForTest() // RoastRetryActive() now false + signing.ResetRoastTransitionRegistryForTest() + if signing.RoastRetryActive() { + t.Fatalf("precondition: registry must be empty (RoastRetryActive false)") + } + legacyB, err := roastSel.Select(ready, ops, seed, 1, 1, honestThreshold, freshSess, memberB) + if err != nil { + t.Fatalf("node B must fall back to legacy when ROAST is inactive: %v", err) + } + if len(legacyB.includedMembersIndexes) != int(honestThreshold) { + t.Fatalf("expected node B legacy set of size %d, got %v", honestThreshold, legacyB.includedMembersIndexes) + } + t.Logf("node B would broadcast the divergent legacy set %v (before the guard engages)", legacyB.includedMembersIndexes) + + // ---- Phase C: guard ON -> the SAME node-B call, and node A, FAIL CLOSED. ---- + registerNodeA() + if !signing.RoastRetryActive() { + t.Fatalf("precondition: ROAST must be active after registering node A") + } + + assertFailClosed := func(who string, sel participantSelection, err error, wantSubstr string) { + t.Helper() + if err == nil { + t.Fatalf("%s must FAIL CLOSED, got selection %v", who, sel.includedMembersIndexes) + } + if errors.Is(err, signing.ErrRoastSelectionFallBackToLegacy) { + t.Fatalf("%s must fail closed, NOT fall back to legacy: %v", who, err) + } + if len(sel.includedMembersIndexes) != 0 { + t.Fatalf("%s fail-closed selection must be empty, got %v", who, sel.includedMembersIndexes) + } + if !strings.Contains(err.Error(), wantSubstr) { + t.Fatalf("%s error %q missing %q", who, err.Error(), wantSubstr) + } + } + + // Node B: unregistered seat under active ROAST -> partial-registration fail-closed. + gotB, errB := roastSel.Select(ready, ops, seed, 1, 1, honestThreshold, freshSess, memberB) + assertFailClosed("node B (would-be legacy)", gotB, errB, "no registered coordinator") + // The guard specifically suppressed the divergent legacy set node B had emitted. + if fracSameMembers(gotB.includedMembersIndexes, legacyB.includedMembersIndexes) { + t.Fatalf("guard failed: node B still emitted the divergent legacy set %v", legacyB.includedMembersIndexes) + } + + // Node A: registered ROAST seat, but no record for freshSess -> missing-record fail-closed. + gotA, errA := roastSel.Select(ready, ops, seed, 1, 1, honestThreshold, freshSess, elected) + assertFailClosed("node A (ROAST-active)", gotA, errA, "no transition record") + + // NEITHER produced a NextAttempt set, so they cannot have converged on + // divergent (or each other's) sets: the network fails closed instead of + // splitting. + if len(gotA.includedMembersIndexes) != 0 || len(gotB.includedMembersIndexes) != 0 { + t.Fatalf("both nodes must emit an EMPTY selection when failing closed; A=%v B=%v", + gotA.includedMembersIndexes, gotB.includedMembersIndexes) + } + + // Determinism: the fail-closed decision is a stable per-seat function of + // registry state, not order/timing dependent. + gotB2, errB2 := roastSel.Select(ready, ops, seed, 1, 1, honestThreshold, freshSess, memberB) + assertFailClosed("node B (repeat)", gotB2, errB2, "no registered coordinator") + gotA2, errA2 := roastSel.Select(ready, ops, seed, 1, 1, honestThreshold, freshSess, elected) + assertFailClosed("node A (repeat)", gotA2, errA2, "no transition record") + t.Logf("both nodes fail closed deterministically (node A: missing record; node B: unregistered seat)") +} diff --git a/pkg/tbtc/signing_loop_selector_default_build.go b/pkg/tbtc/signing_loop_selector_default_build.go new file mode 100644 index 0000000000..3eb237e93f --- /dev/null +++ b/pkg/tbtc/signing_loop_selector_default_build.go @@ -0,0 +1,12 @@ +//go:build !frost_roast_retry + +package tbtc + +// defaultSigningParticipantSelector in the default build always +// returns the legacy retry shuffle. The ROAST-driven selector is +// only compiled into the frost_roast_retry build (see +// signing_loop_selector_frost_roast_retry.go) so the default +// production binary contains no ROAST-retry code paths at all. +func defaultSigningParticipantSelector() signingParticipantSelector { + return legacySigningParticipantSelector{} +} diff --git a/pkg/tbtc/signing_loop_selector_default_build_test.go b/pkg/tbtc/signing_loop_selector_default_build_test.go new file mode 100644 index 0000000000..ffb604197c --- /dev/null +++ b/pkg/tbtc/signing_loop_selector_default_build_test.go @@ -0,0 +1,15 @@ +//go:build !frost_roast_retry + +package tbtc + +import "testing" + +func TestDefaultSigningParticipantSelector_IsLegacyInDefaultBuild(t *testing.T) { + sel := defaultSigningParticipantSelector() + if _, ok := sel.(legacySigningParticipantSelector); !ok { + t.Fatalf( + "defaultSigningParticipantSelector in default build must return legacy implementation; got %T", + sel, + ) + } +} diff --git a/pkg/tbtc/signing_loop_selector_frost_roast_retry.go b/pkg/tbtc/signing_loop_selector_frost_roast_retry.go new file mode 100644 index 0000000000..53aa4a8722 --- /dev/null +++ b/pkg/tbtc/signing_loop_selector_frost_roast_retry.go @@ -0,0 +1,84 @@ +//go:build frost_roast_retry + +package tbtc + +import ( + "errors" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// roastSigningParticipantSelector is installed as the default participant +// selector in the frost_roast_retry build. RFC-21 Phase 7.3 PR2b-1b C3 activates +// it: on a retry under active ROAST retry it consumes this seat's stored +// transition record to select the next attempt's included set at the member +// level; otherwise it uses the legacy retry shuffle. +// +// The transition record is produced by the (C1/C2) observe + bus exchange: every +// signer holds the verified record for the prior attempt, so consuming it here +// no longer fractures the group the way a purely-local record would have. The +// crucial discipline is FAIL-CLOSED: when a committed ROAST attempt expected a +// transition that did not arrive, this selector returns the error (terminating +// the retry loop) rather than falling back to legacy -- mixed ROAST/legacy +// selection across honest nodes is the fracture class. +type roastSigningParticipantSelector struct { + legacy legacySigningParticipantSelector +} + +// defaultSigningParticipantSelector in the frost_roast_retry build returns the +// ROAST selector. +func defaultSigningParticipantSelector() signingParticipantSelector { + return roastSigningParticipantSelector{} +} + +// Select consumes the stored transition record when ROAST retry drives this +// attempt, and otherwise uses the legacy shuffle. See +// signing.ConsumeRoastTransitionForSelection for the three-way contract +// (consume / uniform legacy fallback / fail closed). +func (s roastSigningParticipantSelector) Select( + readyMembersIndexes []group.MemberIndex, + signingGroupOperators chain.Addresses, + seed int64, + retryCount uint, + roastAttemptNumber uint, + honestThreshold uint, + sessionID string, + memberIndex group.MemberIndex, +) (participantSelection, error) { + included, parked, err := signing.ConsumeRoastTransitionForSelection( + sessionID, + memberIndex, + roastAttemptNumber, + honestThreshold, + ) + if err == nil { + return participantSelection{ + includedMembersIndexes: included, + transientlyParkedMembersIndexes: parked, + }, nil + } + + // Initial ROAST attempt or ROAST retry inactive: a uniform legacy fallback + // every honest node makes identically. + if errors.Is(err, signing.ErrRoastSelectionFallBackToLegacy) { + return s.legacy.Select( + readyMembersIndexes, + signingGroupOperators, + seed, + retryCount, + roastAttemptNumber, + honestThreshold, + sessionID, + memberIndex, + ) + } + + // A committed ROAST attempt expected a transition that did not arrive (or + // NextAttempt was infeasible). FAIL CLOSED: surfacing the error terminates the + // retry loop, and the outer layer retries the whole signing. Falling back to + // legacy here -- while peers that DID receive the transition select from it -- + // would split the signing group into divergent included sets. + return participantSelection{}, err +} diff --git a/pkg/tbtc/signing_loop_selector_frost_roast_retry_test.go b/pkg/tbtc/signing_loop_selector_frost_roast_retry_test.go new file mode 100644 index 0000000000..46b19eaae9 --- /dev/null +++ b/pkg/tbtc/signing_loop_selector_frost_roast_retry_test.go @@ -0,0 +1,100 @@ +//go:build frost_roast_retry + +package tbtc + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func selectorTestMembers() []chain.Address { + return []chain.Address{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + chain.Address("op-4"), + chain.Address("op-5"), + } +} + +func TestDefaultSigningParticipantSelector_IsROASTInTaggedBuild(t *testing.T) { + sel := defaultSigningParticipantSelector() + if _, ok := sel.(roastSigningParticipantSelector); !ok { + t.Fatalf( + "defaultSigningParticipantSelector in frost_roast_retry build must return ROAST impl; got %T", + sel, + ) + } +} + +// The initial attempt (retry 0) has no prior transition, so the ROAST selector +// uses the legacy retry shuffle -- a uniform decision every honest node makes. +func TestROASTSelector_InitialAttemptUsesLegacy(t *testing.T) { + signing.ResetRoastRetryRegistrationForTest() + signing.ResetRoastTransitionRegistryForTest() + t.Cleanup(signing.ResetRoastRetryRegistrationForTest) + t.Cleanup(signing.ResetRoastTransitionRegistryForTest) + + sel := roastSigningParticipantSelector{} + // Args: ready, operators, seed, retryCount, roastAttemptNumber, honestThreshold, + // sessionID, memberIndex. roastAttemptNumber 0 == the initial ROAST attempt. + got, err := sel.Select( + []group.MemberIndex{1, 2, 3, 4, 5}, + selectorTestMembers(), + 42, 0, 0, 3, "session", 1, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got.includedMembersIndexes) != 3 { + t.Fatalf( + "expected a legacy-shaped included set (the honest threshold); got %d", + len(got.includedMembersIndexes), + ) + } +} + +// On a retry under ACTIVE ROAST retry, a transition is expected; when none +// arrived the selector must FAIL CLOSED (surface an error that terminates the +// loop) rather than fall back to legacy -- mixed selection across honest nodes +// is the fracture class. C3 activates this consumption. +func TestROASTSelector_FailsClosedWhenTransitionMissing(t *testing.T) { + t.Setenv(signing.RoastRetryReadinessOptInEnvVar, "true") + signing.ResetRoastRetryRegistrationForTest() + signing.ResetRoastTransitionRegistryForTest() + t.Cleanup(signing.ResetRoastRetryRegistrationForTest) + t.Cleanup(signing.ResetRoastTransitionRegistryForTest) + signing.RegisterRoastRetryCoordinator(signing.RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + if !signing.RoastRetryActive() { + // In a frost_roast_retry && !frost_native build there is no transition + // producer, so the selector falls back to legacy rather than fail-closing -- + // the dedicated no-producer test covers that path. The fail-closed discipline + // asserted here only applies when a producer (frost_native) exists. + t.Skip("requires a transition producer (frost_native) for the fail-closed path") + } + + sel := roastSigningParticipantSelector{} + // roastAttemptNumber 1 (> 0) under active ROAST expects a transition; none is + // stored, so the selector must fail closed. + _, err := sel.Select( + []group.MemberIndex{1, 2, 3, 4, 5}, + selectorTestMembers(), + 42, 1, 1, 3, "session", 1, + ) + if err == nil { + t.Fatal("expected a fail-closed error when an expected transition is missing") + } + if errors.Is(err, signing.ErrRoastSelectionFallBackToLegacy) { + t.Fatal("a missing expected transition must fail closed, not fall back to legacy") + } +} diff --git a/pkg/tbtc/signing_loop_test.go b/pkg/tbtc/signing_loop_test.go index 5d9a5caaf6..78ca3dbc7e 100644 --- a/pkg/tbtc/signing_loop_test.go +++ b/pkg/tbtc/signing_loop_test.go @@ -2,6 +2,7 @@ package tbtc import ( "context" + "errors" "fmt" "math" "math/big" @@ -11,9 +12,8 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" ) func TestSigningRetryLoop(t *testing.T) { @@ -46,11 +46,7 @@ func TestSigningRetryLoop(t *testing.T) { } testResult := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(300), - S: big.NewInt(400), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), } var tests = map[string]struct { @@ -607,6 +603,7 @@ func TestSigningRetryLoop(t *testing.T) { retryLoop := newSigningRetryLoop( &testutils.MockLogger{}, message, + "", 200, test.signingGroupMemberIndex, signingGroupOperators, @@ -717,6 +714,7 @@ func TestSigningRetryLoop_GetCurrentBlockErrorCausesRetry(t *testing.T) { retryLoop := newSigningRetryLoop( &testutils.MockLogger{}, message, + "", 200, 1, signingGroupOperators, @@ -755,6 +753,88 @@ func TestSigningRetryLoop_GetCurrentBlockErrorCausesRetry(t *testing.T) { } } +// TestSigningRetryLoop_TerminalErrorAbortsWithoutRetry asserts that a terminal +// signing failure from the attempt function (signing.ErrTerminalSigningFailure, +// e.g. multi-seat interactive ROAST orchestration that is not yet member-safe) +// ABORTS the loop after a single attempt instead of being retried. A retryable +// error would re-invoke the attempt fn until the context deadline; a terminal one +// must surface immediately so the outer wallet layer fails closed cleanly. +func TestSigningRetryLoop_TerminalErrorAbortsWithoutRetry(t *testing.T) { + message := big.NewInt(100) + + groupParameters := &GroupParameters{ + GroupSize: 10, + HonestThreshold: 6, + } + + signingGroupOperators := chain.Addresses{ + "address-1", "address-2", "address-8", "address-4", + "address-2", "address-6", "address-7", "address-8", + "address-9", "address-8", + } + + allMembers := make([]group.MemberIndex, 0, len(signingGroupOperators)) + for i := range signingGroupOperators { + allMembers = append(allMembers, group.MemberIndex(i+1)) + } + + retryLoop := newSigningRetryLoop( + &testutils.MockLogger{}, + message, + "", + 200, + 1, + signingGroupOperators, + groupParameters, + &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func(string) ([]group.MemberIndex, error) { + return allMembers, nil + }, + }, + &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func(uint64) (*signing.Result, uint64, error) { + panic("should not be reached: a terminal error aborts before the done check") + }, + }, + ) + + ctx, cancelCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCtx() + + // A wrapped terminal sentinel, exactly as BeginOrchestrationForSession produces + // for a multi-seat operator. + terminalErr := fmt.Errorf( + "%w: synthetic multi-seat orchestration", + signing.ErrTerminalSigningFailure, + ) + attemptCalls := 0 + + _, err := retryLoop.start( + ctx, + func(context.Context, uint64) error { return nil }, + func() (uint64, error) { return 200, nil }, + func(*signingAttemptParams) (*signing.Result, uint64, error) { + attemptCalls++ + return nil, 0, terminalErr + }, + ) + + if !errors.Is(err, signing.ErrTerminalSigningFailure) { + t.Errorf( + "expected a terminal signing failure to propagate; got [%v]", + err, + ) + } + if attemptCalls != 1 { + t.Errorf( + "terminal error must abort after exactly one attempt (no retry); "+ + "got %d attempt calls", + attemptCalls, + ) + } +} + func TestSigningRetryLoop_WaitForBlockErrorCausesRetry(t *testing.T) { message := big.NewInt(100) @@ -772,6 +852,7 @@ func TestSigningRetryLoop_WaitForBlockErrorCausesRetry(t *testing.T) { retryLoop := newSigningRetryLoop( &testutils.MockLogger{}, message, + "", 200, 1, signingGroupOperators, @@ -825,6 +906,7 @@ func TestSigningRetryLoop_ContextCancelled(t *testing.T) { retryLoop := newSigningRetryLoop( &testutils.MockLogger{}, big.NewInt(100), + "", 200, 1, signingGroupOperators, @@ -877,11 +959,7 @@ func TestSigningRetryLoop_SuccessAfterRetry(t *testing.T) { } testResult := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(300), - S: big.NewInt(400), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), } // Session IDs use fmt.Sprintf("%v-%v", message, attemptCounter). @@ -890,6 +968,7 @@ func TestSigningRetryLoop_SuccessAfterRetry(t *testing.T) { retryLoop := newSigningRetryLoop( &testutils.MockLogger{}, message, + "", 200, 1, signingGroupOperators, @@ -993,3 +1072,107 @@ func (msdc *mockSigningDoneCheck) signalDone( func (msdc *mockSigningDoneCheck) waitUntilAllDone(ctx context.Context) (*signing.Result, uint64, error) { return msdc.waitUntilAllDoneOutcomeFn(msdc.currentAttemptNumber) } + +func TestSigningRetryLoop_AttemptOutcomeReporting(t *testing.T) { + message := big.NewInt(100) + + groupParameters := &GroupParameters{ + GroupSize: 10, + HonestThreshold: 6, + } + + signingGroupOperators := chain.Addresses{ + "address-1", "address-2", "address-8", "address-4", "address-2", + "address-6", "address-7", "address-8", "address-9", "address-8", + } + + signingGroupMembersIndexes := make([]group.MemberIndex, 0) + for i := range signingGroupOperators { + signingGroupMembersIndexes = append( + signingGroupMembersIndexes, + group.MemberIndex(i+1), + ) + } + + testResult := &signing.Result{ + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), + } + + announcer := &mockSigningAnnouncer{ + outgoingAnnouncements: make(map[string]group.MemberIndex), + incomingAnnouncementsFn: func( + sessionID string, + ) ([]group.MemberIndex, error) { + return signingGroupMembersIndexes, nil + }, + } + + doneCheck := &mockSigningDoneCheck{ + waitUntilAllDoneOutcomeFn: func( + attemptNumber uint64, + ) (*signing.Result, uint64, error) { + if attemptNumber == 1 { + return nil, 0, fmt.Errorf("done check timeout") + } + return testResult, 250, nil + }, + } + + retryLoop := newSigningRetryLoop( + &testutils.MockLogger{}, + message, + "", + 200, + group.MemberIndex(1), + signingGroupOperators, + groupParameters, + announcer, + doneCheck, + ) + + reportedOutcomes := make([]bool, 0) + retryLoop.setAttemptOutcomeReporter(func(success bool) { + reportedOutcomes = append(reportedOutcomes, success) + }) + + ctx, cancelCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCtx() + + result, err := retryLoop.start( + ctx, + func(context.Context, uint64) error { + return nil + }, + func() (uint64, error) { + return 200, nil + }, + func(params *signingAttemptParams) (*signing.Result, uint64, error) { + // The first attempt's protocol run fails; subsequent ones + // succeed. Regardless of whether this member is included in + // the second attempt's subset, the attempt outcome must be + // reported exactly once per attempt: failure for the first, + // success for the second. + if params.number == 1 { + return nil, 0, fmt.Errorf("protocol failure") + } + return testResult, 250, nil + }, + ) + if err != nil { + t.Fatalf("unexpected retry loop error: [%v]", err) + } + if result == nil { + t.Fatal("expected a non-nil retry loop result") + } + + expectedOutcomes := []bool{false, true} + if !reflect.DeepEqual(expectedOutcomes, reportedOutcomes) { + t.Errorf( + "unexpected reported attempt outcomes\n"+ + "expected: [%v]\n"+ + "actual: [%v]", + expectedOutcomes, + reportedOutcomes, + ) + } +} diff --git a/pkg/tbtc/signing_native_backend_frost_native_test.go b/pkg/tbtc/signing_native_backend_frost_native_test.go new file mode 100644 index 0000000000..67429b3fbc --- /dev/null +++ b/pkg/tbtc/signing_native_backend_frost_native_test.go @@ -0,0 +1,909 @@ +//go:build frost_native + +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "reflect" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type countingNativeExecutionFFISigningPrimitive struct { + signCalls atomic.Int64 +} + +type deterministicNativeExecutionFFISigningPrimitiveForTBTC struct { + signCalls atomic.Int64 +} + +type attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC struct { + signCalls atomic.Int64 + mutex sync.Mutex + records []attemptTrackingRecordForTBTC +} + +type attemptTrackingRecordForTBTC struct { + attemptNumber uint + includedMemberIndex []group.MemberIndex +} + +type attemptTrackingNativeTBTCSignerEngineForTBTC struct { + mutex sync.Mutex + startCohortsByAttempt map[uint][][]uint16 +} + +var deterministicNativeFROSTSignatureForTBTC = [frost.SignatureSize]byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, +} + +func (cnefsp *countingNativeExecutionFFISigningPrimitive) Sign( + ctx context.Context, + logger log.StandardLogger, + request *frostsigning.NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + cnefsp.signCalls.Add(1) + return &frost.Signature{}, nil +} + +func (cnefsp *countingNativeExecutionFFISigningPrimitive) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} + +func (dnefspf *deterministicNativeExecutionFFISigningPrimitiveForTBTC) Sign( + ctx context.Context, + logger log.StandardLogger, + request *frostsigning.NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + dnefspf.signCalls.Add(1) + + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + nativeSignerMaterial := request.SignerMaterial + if nativeSignerMaterial == nil { + return nil, fmt.Errorf("native signer material is nil") + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1 { + return nil, fmt.Errorf( + "unexpected signer material format: [%s]", + nativeSignerMaterial.Format, + ) + } + + signature := &frost.Signature{} + if err := signature.Unmarshal(deterministicNativeFROSTSignatureForTBTC[:]); err != nil { + return nil, err + } + + return signature, nil +} + +func (dnefspf *deterministicNativeExecutionFFISigningPrimitiveForTBTC) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} + +func (atnefspf *attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC) Sign( + ctx context.Context, + logger log.StandardLogger, + request *frostsigning.NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + atnefspf.signCalls.Add(1) + + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Attempt == nil { + return nil, fmt.Errorf("request attempt is nil") + } + + atnefspf.mutex.Lock() + atnefspf.records = append( + atnefspf.records, + attemptTrackingRecordForTBTC{ + attemptNumber: request.Attempt.Number, + includedMemberIndex: append( + []group.MemberIndex{}, + request.Attempt.IncludedMembersIndexes..., + ), + }, + ) + atnefspf.mutex.Unlock() + + // Force retry-loop progression so the next attempt is exercised. + if request.Attempt.Number == 1 { + return nil, fmt.Errorf("forced attempt failure") + } + + signature := &frost.Signature{} + if err := signature.Unmarshal(deterministicNativeFROSTSignatureForTBTC[:]); err != nil { + return nil, err + } + + return signature, nil +} + +func (atnefspf *attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} + +func (atnefspf *attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC) uniqueCohortsByAttempt() map[uint][][]group.MemberIndex { + atnefspf.mutex.Lock() + defer atnefspf.mutex.Unlock() + + result := make(map[uint][][]group.MemberIndex) + seen := make(map[uint]map[string]struct{}) + + for _, record := range atnefspf.records { + if seen[record.attemptNumber] == nil { + seen[record.attemptNumber] = make(map[string]struct{}) + } + + keyParts := make([]string, 0, len(record.includedMemberIndex)) + for _, memberIndex := range record.includedMemberIndex { + keyParts = append( + keyParts, + strconv.FormatUint(uint64(memberIndex), 10), + ) + } + cohortKey := strings.Join(keyParts, ",") + + if _, ok := seen[record.attemptNumber][cohortKey]; ok { + continue + } + + seen[record.attemptNumber][cohortKey] = struct{}{} + result[record.attemptNumber] = append( + result[record.attemptNumber], + append([]group.MemberIndex{}, record.includedMemberIndex...), + ) + } + + return result +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) Version() (string, error) { + return "tbtc-signer/0.1.0-bootstrap", nil +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) RunDKG( + sessionID string, + participants []frostsigning.NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*frostsigning.NativeTBTCSignerDKGResult, error) { + return &frostsigning.NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + taprootMerkleRoot *[32]byte, +) (*frostsigning.NativeTBTCSignerRoundState, error) { + _ = taprootMerkleRoot + + attemptNumber, err := attemptNumberFromSessionIDForTBTC(sessionID) + if err != nil { + return nil, err + } + + if keyGroup == "" { + return nil, fmt.Errorf("key group is empty") + } + + if memberIdentifier == 0 { + return nil, fmt.Errorf("member identifier is zero") + } + + if len(message) == 0 { + return nil, fmt.Errorf("message is empty") + } + + if len(signingParticipants) == 0 { + return nil, fmt.Errorf("signing participants are empty") + } + + atntsfe.mutex.Lock() + if atntsfe.startCohortsByAttempt == nil { + atntsfe.startCohortsByAttempt = make(map[uint][][]uint16) + } + + cohort := append([]uint16{}, signingParticipants...) + atntsfe.startCohortsByAttempt[attemptNumber] = append( + atntsfe.startCohortsByAttempt[attemptNumber], + cohort, + ) + atntsfe.mutex.Unlock() + + return &frostsigning.NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: fmt.Sprintf("round-%v", attemptNumber), + RequiredContributions: uint16(len(signingParticipants)), + MessageDigestHex: "00", + SigningParticipants: append([]uint16{}, signingParticipants...), + OwnContribution: &frostsigning.NativeTBTCSignerRoundContribution{ + Identifier: memberIdentifier, + Data: []byte{byte(memberIdentifier), byte(attemptNumber)}, + }, + }, nil +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) FinalizeSignRound( + sessionID string, + roundContributions []frostsigning.NativeTBTCSignerRoundContribution, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + _ = taprootMerkleRoot + + if _, err := attemptNumberFromSessionIDForTBTC(sessionID); err != nil { + return nil, err + } + + if len(roundContributions) == 0 { + return nil, fmt.Errorf("round contributions are empty") + } + + return []byte{0xaa}, nil +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) BuildTaprootTx( + sessionID string, + inputs []frostsigning.NativeTBTCSignerTxInput, + outputs []frostsigning.NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*frostsigning.NativeTBTCSignerTxResult, error) { + return nil, fmt.Errorf("not implemented") +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) VerifySignatureShare( + sessionID string, + signingPackage []byte, + signatureShare []byte, + memberIdentifier uint16, + taprootMerkleRoot *[32]byte, +) (frostsigning.NativeShareVerificationVerdict, error) { + return frostsigning.NativeShareVerdictIndeterminate, fmt.Errorf("not implemented") +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) uniqueStartCohortsByAttempt() map[uint][][]uint16 { + atntsfe.mutex.Lock() + defer atntsfe.mutex.Unlock() + + result := make(map[uint][][]uint16) + seen := make(map[uint]map[string]struct{}) + + for attemptNumber, cohorts := range atntsfe.startCohortsByAttempt { + if seen[attemptNumber] == nil { + seen[attemptNumber] = make(map[string]struct{}) + } + + for _, cohort := range cohorts { + parts := make([]string, 0, len(cohort)) + for _, participant := range cohort { + parts = append(parts, strconv.FormatUint(uint64(participant), 10)) + } + key := strings.Join(parts, ",") + + if _, ok := seen[attemptNumber][key]; ok { + continue + } + + seen[attemptNumber][key] = struct{}{} + result[attemptNumber] = append(result[attemptNumber], append([]uint16{}, cohort...)) + } + } + + return result +} + +func attemptNumberFromSessionIDForTBTC(sessionID string) (uint, error) { + separatorIndex := strings.LastIndex(sessionID, "-") + if separatorIndex < 0 || separatorIndex == len(sessionID)-1 { + return 0, fmt.Errorf("invalid session id format: [%s]", sessionID) + } + + attemptNumber, err := strconv.ParseUint(sessionID[separatorIndex+1:], 10, 64) + if err != nil { + return 0, fmt.Errorf("cannot parse attempt number from session id [%s]: [%w]", sessionID, err) + } + + return uint(attemptNumber), nil +} + +func TestConfigureFrostSigningBackend_FFIStrictConfigured_BuildAdapter(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected strict ffi backend configuration error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func TestConfigureFrostSigningBackend_FFIStrictUnavailable_NoBridge(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + // Remove build-registered bridge and executor to exercise strict ffi + // configuration when no native cryptography path is available. + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err == nil { + t.Fatal("expected strict ffi backend configuration error") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected strict ffi backend error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } + + if !errors.Is(err, frostsigning.ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected strict ffi native-availability error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeCryptographyUnavailable, + err, + ) + } +} + +func TestSigningExecutor_Sign_NativeBackend(t *testing.T) { + executor := setupSigningExecutor(t) + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "native"}) + if err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected native backend signing error: [%v]", err) + } + + // Transitional path note: + // The current native-tag adapter delegates to legacy tECDSA signing. + // Switch this verification to Schnorr/BIP-340 once native FROST crypto + // execution is linked. + walletPublicKey := executor.wallet().publicKey + if !ecdsa.Verify( + walletPublicKey, + message.Bytes(), + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), + ) { + t.Fatalf("invalid signature: [%+v]", signature) + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func TestSigningExecutor_Sign_FFIStrictBackend_WithNativeSignerMaterial( + t *testing.T, +) { + executor := setupSigningExecutor(t) + configureSignersWithTBTCSignerMaterial(t, executor, 0) + + primitive := &deterministicNativeExecutionFFISigningPrimitiveForTBTC{} + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + err := frostsigning.RegisterNativeExecutionFFISigningPrimitive(primitive) + if err != nil { + t.Fatalf("unexpected native FFI primitive registration error: [%v]", err) + } + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected strict ffi backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected strict ffi signing error: [%v]", err) + } + + signatureBytes, err := signature.Marshal() + if err != nil { + t.Fatalf("cannot marshal signature: [%v]", err) + } + + if !bytes.Equal(signatureBytes, deterministicNativeFROSTSignatureForTBTC[:]) { + t.Fatalf( + "unexpected native FROST signature\nexpected: [%x]\nactual: [%x]", + deterministicNativeFROSTSignatureForTBTC[:], + signatureBytes, + ) + } + + if primitive.signCalls.Load() == 0 { + t.Fatal("expected native FFI primitive sign call") + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func TestSigningExecutor_Sign_NativeBackend_FallsBackWhenOnlyLegacySignerMaterial( + t *testing.T, +) { + executor := setupSigningExecutor(t) + + // Force legacy-only signer material to exercise fallback classification + // behavior even when frost_native build defaults resolve to native material. + for _, signer := range executor.signers { + signer.signerMaterial = signer.privateKeyShare + } + + primitive := &countingNativeExecutionFFISigningPrimitive{} + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + err := frostsigning.RegisterNativeExecutionFFISigningPrimitive(primitive) + if err != nil { + t.Fatalf("unexpected native FFI primitive registration error: [%v]", err) + } + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "native"}) + if err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected native backend signing error: [%v]", err) + } + + if primitive.signCalls.Load() != 0 { + t.Fatalf( + "unexpected native primitive sign calls count\nexpected: [%d]\nactual: [%d]", + 0, + primitive.signCalls.Load(), + ) + } + + walletPublicKey := executor.wallet().publicKey + if !ecdsa.Verify( + walletPublicKey, + message.Bytes(), + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), + ) { + t.Fatalf("invalid signature: [%+v]", signature) + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func TestSigningExecutor_Sign_FFIStrictBackend_AttemptVariationChangesCohortSelection( + t *testing.T, +) { + executor := setupSigningExecutor(t) + configureSignersWithTBTCSignerMaterial(t, executor, 0) + + primitive := &attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC{} + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + err := frostsigning.RegisterNativeExecutionFFISigningPrimitive(primitive) + if err != nil { + t.Fatalf("unexpected native FFI primitive registration error: [%v]", err) + } + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected strict ffi backend config error: [%v]", err) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected strict ffi signing error: [%v]", err) + } + + signatureBytes, err := signature.Marshal() + if err != nil { + t.Fatalf("cannot marshal signature: [%v]", err) + } + + if !bytes.Equal(signatureBytes, deterministicNativeFROSTSignatureForTBTC[:]) { + t.Fatalf( + "unexpected native FROST signature\nexpected: [%x]\nactual: [%x]", + deterministicNativeFROSTSignatureForTBTC[:], + signatureBytes, + ) + } + + if primitive.signCalls.Load() == 0 { + t.Fatal("expected native FFI primitive sign call") + } + + cohortsByAttempt := primitive.uniqueCohortsByAttempt() + attemptOneCohorts, ok := cohortsByAttempt[1] + if !ok { + t.Fatal("expected observed cohort for attempt 1") + } + if len(attemptOneCohorts) != 1 { + t.Fatalf( + "unexpected unique cohort count for attempt 1\nexpected: [%d]\nactual: [%d]", + 1, + len(attemptOneCohorts), + ) + } + + attemptTwoCohorts, ok := cohortsByAttempt[2] + if !ok { + t.Fatal("expected observed cohort for attempt 2") + } + if len(attemptTwoCohorts) != 1 { + t.Fatalf( + "unexpected unique cohort count for attempt 2\nexpected: [%d]\nactual: [%d]", + 1, + len(attemptTwoCohorts), + ) + } + + attemptOneCohort := attemptOneCohorts[0] + attemptTwoCohort := attemptTwoCohorts[0] + + expectedCohortSize := executor.groupParameters.HonestThreshold + if len(attemptOneCohort) != expectedCohortSize { + t.Fatalf( + "unexpected cohort size for attempt 1\nexpected: [%d]\nactual: [%d]", + expectedCohortSize, + len(attemptOneCohort), + ) + } + if len(attemptTwoCohort) != expectedCohortSize { + t.Fatalf( + "unexpected cohort size for attempt 2\nexpected: [%d]\nactual: [%d]", + expectedCohortSize, + len(attemptTwoCohort), + ) + } + + if reflect.DeepEqual(attemptOneCohort, attemptTwoCohort) { + t.Fatalf( + "expected cohort variation across attempts\nattempt 1: [%v]\nattempt 2: [%v]", + attemptOneCohort, + attemptTwoCohort, + ) + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func TestSigningExecutor_Sign_FFIStrictBackend_TBTCSignerPath_AttemptVariationChangesCohortSelection( + t *testing.T, +) { + executor := setupSigningExecutor(t) + configureSignersWithTBTCSignerMaterial(t, executor, 3) + + nativeTBTCSignerEngine := &attemptTrackingNativeTBTCSignerEngineForTBTC{} + + frostsigning.UnregisterNativeTBTCSignerEngine() + frostsigning.UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(frostsigning.UnregisterNativeTBTCSignerEngine) + t.Cleanup(frostsigning.UnregisterNativeTBTCSignerFallbackObserver) + + var fallbackEvents []frostsigning.NativeTBTCSignerFallbackEvent + err := frostsigning.RegisterNativeTBTCSignerFallbackObserver( + func(event frostsigning.NativeTBTCSignerFallbackEvent) { + fallbackEvents = append(fallbackEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected fallback observer registration error: [%v]", err) + } + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + err = frostsigning.RegisterNativeTBTCSignerEngine(nativeTBTCSignerEngine) + if err != nil { + t.Fatalf("unexpected native tbtc-signer engine registration error: [%v]", err) + } + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected strict ffi backend config error: [%v]", err) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected strict ffi tbtc-signer-path signing error: [%v]", err) + } + + walletPublicKey := executor.wallet().publicKey + if !ecdsa.Verify( + walletPublicKey, + message.Bytes(), + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), + ) { + t.Fatalf("invalid signature: [%+v]", signature) + } + + cohortsByAttempt := nativeTBTCSignerEngine.uniqueStartCohortsByAttempt() + attemptOneCohorts, ok := cohortsByAttempt[1] + if !ok { + t.Fatal("expected observed StartSignRound cohort for attempt 1") + } + if len(attemptOneCohorts) != 1 { + t.Fatalf( + "unexpected unique cohort count for attempt 1\nexpected: [%d]\nactual: [%d]", + 1, + len(attemptOneCohorts), + ) + } + + attemptTwoCohorts, ok := cohortsByAttempt[2] + if !ok { + t.Fatal("expected observed StartSignRound cohort for attempt 2") + } + if len(attemptTwoCohorts) != 1 { + t.Fatalf( + "unexpected unique cohort count for attempt 2\nexpected: [%d]\nactual: [%d]", + 1, + len(attemptTwoCohorts), + ) + } + + attemptOneCohort := attemptOneCohorts[0] + attemptTwoCohort := attemptTwoCohorts[0] + + expectedCohortSize := executor.groupParameters.HonestThreshold + if len(attemptOneCohort) != expectedCohortSize { + t.Fatalf( + "unexpected cohort size for attempt 1\nexpected: [%d]\nactual: [%d]", + expectedCohortSize, + len(attemptOneCohort), + ) + } + if len(attemptTwoCohort) != expectedCohortSize { + t.Fatalf( + "unexpected cohort size for attempt 2\nexpected: [%d]\nactual: [%d]", + expectedCohortSize, + len(attemptTwoCohort), + ) + } + + if !containsParticipantForTBTC(attemptOneCohort, 3) { + t.Fatalf( + "expected attempt 1 cohort to include broken signer member 3\nactual: [%v]", + attemptOneCohort, + ) + } + + if containsParticipantForTBTC(attemptTwoCohort, 3) { + t.Fatalf( + "expected attempt 2 cohort to exclude broken signer member 3\nactual: [%v]", + attemptTwoCohort, + ) + } + + if reflect.DeepEqual(attemptOneCohort, attemptTwoCohort) { + t.Fatalf( + "expected cohort variation across attempts\nattempt 1: [%v]\nattempt 2: [%v]", + attemptOneCohort, + attemptTwoCohort, + ) + } + + missingLegacyFallbackObserved := false + for _, event := range fallbackEvents { + if !event.LegacyPrivateKeyShareExists { + missingLegacyFallbackObserved = true + break + } + } + if !missingLegacyFallbackObserved { + t.Fatal("expected at least one fallback event without legacy private key share") + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func configureSignersWithTBTCSignerMaterial( + t *testing.T, + executor *signingExecutor, + brokenMemberIndex group.MemberIndex, +) { + t.Helper() + + for _, signer := range executor.signers { + legacyPrivateKeyShareHex := "" + if signer.signingGroupMemberIndex != brokenMemberIndex { + legacyPrivateKeySharePayload, err := signer.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("cannot marshal private key share: [%v]", err) + } + + legacyPrivateKeyShareHex = hex.EncodeToString(legacyPrivateKeySharePayload) + } + + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: legacyPrivateKeyShareHex, + }) + if err != nil { + t.Fatalf("cannot marshal tbtc-signer material payload: [%v]", err) + } + + signer.signerMaterial = &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + } +} + +func containsParticipantForTBTC(cohort []uint16, memberIndex uint16) bool { + for _, participant := range cohort { + if participant == memberIndex { + return true + } + } + + return false +} diff --git a/pkg/tbtc/signing_runtime_helpers_test.go b/pkg/tbtc/signing_runtime_helpers_test.go new file mode 100644 index 0000000000..42418e03d0 --- /dev/null +++ b/pkg/tbtc/signing_runtime_helpers_test.go @@ -0,0 +1,34 @@ +package tbtc + +import ( + "math/big" + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestAttemptIncludedMembersIndexes(t *testing.T) { + included := attemptIncludedMembersIndexes( + 6, + []group.MemberIndex{6, 2, 4, 2}, + ) + + expected := []group.MemberIndex{1, 3, 5} + if !reflect.DeepEqual(expected, included) { + t.Fatalf("unexpected included members\nexpected: [%v]\nactual: [%v]", expected, included) + } +} + +func TestSigningAttemptSeed(t *testing.T) { + first := signingAttemptSeed(big.NewInt(100)) + again := signingAttemptSeed(big.NewInt(100)) + if first != again { + t.Fatalf("seed should be stable\nfirst: [%v]\nagain: [%v]", first, again) + } + + second := signingAttemptSeed(big.NewInt(101)) + if first == second { + t.Fatal("different messages should produce different attempt seeds") + } +} diff --git a/pkg/tbtc/signing_schnorr_default.go b/pkg/tbtc/signing_schnorr_default.go new file mode 100644 index 0000000000..3074107327 --- /dev/null +++ b/pkg/tbtc/signing_schnorr_default.go @@ -0,0 +1,46 @@ +//go:build !frost_native + +package tbtc + +import ( + "encoding/json" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func signingMaterialUsesSchnorrSignatures(signingMaterial any) bool { + switch material := signingMaterial.(type) { + case *tecdsa.PrivateKeyShare: + return false + case *frostsigning.NativeSignerMaterial: + return nativeSignerMaterialUsesSchnorrSignaturesDefault(material) + case frostsigning.NativeSignerMaterial: + return nativeSignerMaterialUsesSchnorrSignaturesDefault(&material) + default: + return true + } +} + +func nativeSignerMaterialUsesSchnorrSignaturesDefault( + material *frostsigning.NativeSignerMaterial, +) bool { + if material == nil { + return true + } + + switch material.Format { + case frostsigning.NativeSignerMaterialFormatFrostUniFFIV1: + return false + case frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1: + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(material.Payload, &payload); err != nil { + return true + } + + return payload.KeyGroupSource != + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey + default: + return true + } +} diff --git a/pkg/tbtc/signing_schnorr_default_test.go b/pkg/tbtc/signing_schnorr_default_test.go new file mode 100644 index 0000000000..0903830547 --- /dev/null +++ b/pkg/tbtc/signing_schnorr_default_test.go @@ -0,0 +1,92 @@ +//go:build !frost_native + +package tbtc + +import ( + "encoding/json" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestSigningMaterialUsesSchnorrSignatures_Default(t *testing.T) { + tbtcSignerPayload := func(t *testing.T, keyGroupSource string) []byte { + t.Helper() + + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: "key-group", + KeyGroupSource: keyGroupSource, + }) + if err != nil { + t.Fatalf("cannot marshal tbtc-signer payload: [%v]", err) + } + + return payload + } + + tests := map[string]struct { + material any + expectSchnorr bool + }{ + "legacy tecdsa private key share": { + material: &tecdsa.PrivateKeyShare{}, + expectSchnorr: false, + }, + "legacy frost uniffi v1 material": { + material: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0x01}, + }, + expectSchnorr: false, + }, + "legacy tbtc-signer scaffold material": { + material: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: tbtcSignerPayload( + t, + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + ), + }, + expectSchnorr: false, + }, + "native tbtc-signer material": { + material: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: tbtcSignerPayload(t, "dkg-persisted"), + }, + expectSchnorr: true, + }, + "malformed tbtc-signer material": { + material: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte("not-json"), + }, + expectSchnorr: true, + }, + "unknown native material": { + material: &frostsigning.NativeSignerMaterial{ + Format: "unknown", + Payload: []byte{0x01}, + }, + expectSchnorr: true, + }, + "unknown material": { + material: struct{}{}, + expectSchnorr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + actual := signingMaterialUsesSchnorrSignatures(test.material) + if actual != test.expectSchnorr { + t.Fatalf( + "unexpected Schnorr classification\nexpected: [%v]\nactual: [%v]", + test.expectSchnorr, + actual, + ) + } + }) + } +} diff --git a/pkg/tbtc/signing_schnorr_frost_native.go b/pkg/tbtc/signing_schnorr_frost_native.go new file mode 100644 index 0000000000..62860478c1 --- /dev/null +++ b/pkg/tbtc/signing_schnorr_frost_native.go @@ -0,0 +1,48 @@ +//go:build frost_native + +package tbtc + +import ( + "encoding/json" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func signingMaterialUsesSchnorrSignatures(signingMaterial any) bool { + switch material := signingMaterial.(type) { + case *tecdsa.PrivateKeyShare: + return false + case *frostsigning.NativeSignerMaterial: + return nativeSignerMaterialUsesSchnorrSignatures(material) + case frostsigning.NativeSignerMaterial: + return nativeSignerMaterialUsesSchnorrSignatures(&material) + default: + return true + } +} + +func nativeSignerMaterialUsesSchnorrSignatures( + material *frostsigning.NativeSignerMaterial, +) bool { + if material == nil { + return true + } + + switch material.Format { + case frostsigning.NativeSignerMaterialFormatFrostUniFFIV1: + return false + case frostsigning.NativeSignerMaterialFormatFrostUniFFIV2: + return true + case frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1: + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(material.Payload, &payload); err != nil { + return true + } + + return payload.KeyGroupSource != + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey + default: + return true + } +} diff --git a/pkg/tbtc/signing_schnorr_frost_native_test.go b/pkg/tbtc/signing_schnorr_frost_native_test.go new file mode 100644 index 0000000000..e411242963 --- /dev/null +++ b/pkg/tbtc/signing_schnorr_frost_native_test.go @@ -0,0 +1,92 @@ +//go:build frost_native + +package tbtc + +import ( + "encoding/json" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestSigningMaterialUsesSchnorrSignatures_FrostNative(t *testing.T) { + tbtcSignerPayload := func(t *testing.T, keyGroupSource string) []byte { + t.Helper() + + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: "key-group", + KeyGroupSource: keyGroupSource, + }) + if err != nil { + t.Fatalf("cannot marshal tbtc-signer payload: [%v]", err) + } + + return payload + } + + tests := map[string]struct { + material any + expectSchnorr bool + }{ + "legacy tecdsa private key share": { + material: &tecdsa.PrivateKeyShare{}, + expectSchnorr: false, + }, + "legacy frost uniffi v1 material": { + material: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0x01}, + }, + expectSchnorr: false, + }, + "legacy tbtc-signer scaffold material": { + material: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: tbtcSignerPayload( + t, + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + ), + }, + expectSchnorr: false, + }, + "unsupported uniffi frost material": { + material: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV2, + Payload: []byte{0x01}, + }, + expectSchnorr: true, + }, + "native tbtc-signer material": { + material: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: tbtcSignerPayload(t, "dkg-persisted"), + }, + expectSchnorr: true, + }, + "malformed tbtc-signer material": { + material: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte("not-json"), + }, + expectSchnorr: true, + }, + "unknown material": { + material: struct{}{}, + expectSchnorr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + actual := signingMaterialUsesSchnorrSignatures(test.material) + if actual != test.expectSchnorr { + t.Fatalf( + "unexpected Schnorr classification\nexpected: [%v]\nactual: [%v]", + test.expectSchnorr, + actual, + ) + } + }) + } +} diff --git a/pkg/tbtc/signing_test.go b/pkg/tbtc/signing_test.go index 3e7367fa43..7bb196401f 100644 --- a/pkg/tbtc/signing_test.go +++ b/pkg/tbtc/signing_test.go @@ -20,6 +20,116 @@ import ( "github.com/keep-network/keep-core/pkg/tecdsa" ) +func TestSigningSessionID_LegacyFormat(t *testing.T) { + message, ok := new(big.Int).SetString( + "ac692bb7fddf3f7e1e050a83cf3ffb6e8e69888ce980281aa39da169525750ef", + 16, + ) + if !ok { + t.Fatal("failed to build test message") + } + + sessionID := signingSessionID(message, nil, 25300, 12) + + expected := "ac692bb7fddf3f7e1e050a83cf3ffb6e8e69888ce980281aa39da169525750ef-12" + if sessionID != expected { + t.Fatalf( + "unexpected signing session ID\nexpected: [%s]\nactual: [%s]", + expected, + sessionID, + ) + } +} + +func TestSigningSessionID_TaprootFormatStaysWithinSignerLimit(t *testing.T) { + message, ok := new(big.Int).SetString( + "ac692bb7fddf3f7e1e050a83cf3ffb6e8e69888ce980281aa39da169525750ef", + 16, + ) + if !ok { + t.Fatal("failed to build test message") + } + + var merkleRoot [32]byte + for i := range merkleRoot { + merkleRoot[i] = byte(i + 1) + } + + sessionID := signingSessionID(message, &merkleRoot, 25300, 12) + + if len(sessionID) > 128 { + t.Fatalf("Taproot signing session ID exceeds signer limit: [%d]", len(sessionID)) + } + if !strings.HasPrefix(sessionID, "tr-") { + t.Fatalf("unexpected Taproot signing session ID prefix: [%s]", sessionID) + } + if !strings.HasSuffix(sessionID, "-12") { + t.Fatalf("unexpected Taproot signing session ID attempt suffix: [%s]", sessionID) + } + + changedMerkleRoot := merkleRoot + changedMerkleRoot[0] ^= 0xff + if signingSessionID(message, &changedMerkleRoot, 25300, 12) == sessionID { + t.Fatal("expected Taproot signing session ID to bind the merkle root") + } + + if signingSessionID(message, &merkleRoot, 25300, 13) == sessionID { + t.Fatal("expected Taproot signing session ID to bind the attempt number") + } + + if signingSessionID(message, &merkleRoot, 28900, 12) == sessionID { + t.Fatal("expected Taproot signing session ID to bind the signing start block") + } +} + +func TestRoastSessionID_StableAndBinds(t *testing.T) { + message, ok := new(big.Int).SetString( + "ac692bb7fddf3f7e1e050a83cf3ffb6e8e69888ce980281aa39da169525750ef", + 16, + ) + if !ok { + t.Fatal("failed to build test message") + } + + // Key-path (nil root): the stable id binds message + startBlock, but takes + // no attempt number (stable across attempts by construction). + keyPath := roastSessionID(message, nil, 25300) + if !strings.HasPrefix(keyPath, "roast-") { + t.Fatalf("roast session id must be namespaced; got [%s]", keyPath) + } + if roastSessionID(message, nil, 28900) == keyPath { + t.Fatal("key-path roast session id must bind the start block") + } + other, _ := new(big.Int).SetString("01", 16) + if roastSessionID(other, nil, 25300) == keyPath { + t.Fatal("key-path roast session id must bind the message") + } + + // Taproot branch: binds root + startBlock. + var merkleRoot [32]byte + for i := range merkleRoot { + merkleRoot[i] = byte(i + 1) + } + taproot := roastSessionID(message, &merkleRoot, 25300) + if !strings.HasPrefix(taproot, "roast-tr-") { + t.Fatalf("unexpected taproot roast session id prefix; got [%s]", taproot) + } + changed := merkleRoot + changed[0] ^= 0xff + if roastSessionID(message, &changed, 25300) == taproot { + t.Fatal("taproot roast session id must bind the merkle root") + } + if roastSessionID(message, &merkleRoot, 28900) == taproot { + t.Fatal("taproot roast session id must bind the start block") + } + + // The stable roast id must be disjoint from any attempt-specific + // signingSessionID so they never share a registry namespace. + if keyPath == signingSessionID(message, nil, 25300, 12) { + t.Fatal("roast and signing session ids must not collide") + } +} + func TestSigningExecutor_Sign(t *testing.T) { executor := setupSigningExecutor(t) @@ -39,8 +149,8 @@ func TestSigningExecutor_Sign(t *testing.T) { if !ecdsa.Verify( walletPublicKey, message.Bytes(), - signature.R, - signature.S, + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), ) { t.Errorf("invalid signature: [%+v]", signature) } @@ -100,8 +210,8 @@ func TestSigningExecutor_SignBatch(t *testing.T) { if !ecdsa.Verify( walletPublicKey, messages[i].Bytes(), - signature.R, - signature.S, + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), ) { t.Errorf("invalid signature [%v]: [%+v]", i, signature) } @@ -194,6 +304,14 @@ func TestSigningExecutor_SignBatch_PartialFailure(t *testing.T) { // setupSigningExecutor sets up an instance of the signing executor ready // to perform test signing. func setupSigningExecutor(t *testing.T) *signingExecutor { + // Tests in this suite exercise the keep-tbtc signing executor against + // in-process tECDSA fixtures. Under the `frost_native frost_tbtc_signer` + // build tags, the signer-material resolver refuses scaffold-era + // (legacy-wallet-pubkey) material by default; the fixtures here are + // inherently scaffold-era so the executor needs the operator opt-in to + // continue running. Production deployments must never set this env var. + t.Setenv("KEEP_CORE_FROST_TBTC_SIGNER_ACCEPT_SCAFFOLD_KEY_GROUP", "true") + groupParameters := &GroupParameters{ GroupSize: 5, GroupQuorum: 4, diff --git a/pkg/tbtc/taproot_test_helpers_test.go b/pkg/tbtc/taproot_test_helpers_test.go new file mode 100644 index 0000000000..7c6d302702 --- /dev/null +++ b/pkg/tbtc/taproot_test_helpers_test.go @@ -0,0 +1,83 @@ +package tbtc + +import ( + "crypto/ecdsa" + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec" + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +func testWalletPublicKeyFromXOnly(t *testing.T, xOnlyHex string) *ecdsa.PublicKey { + t.Helper() + + xOnlyBytes, err := hex.DecodeString(xOnlyHex) + if err != nil { + t.Fatalf("cannot decode x-only key: [%v]", err) + } + + compressedPublicKey := append([]byte{0x02}, xOnlyBytes...) + parsedPublicKey, err := btcec.ParsePubKey(compressedPublicKey, btcec.S256()) + if err != nil { + t.Fatalf("cannot parse compressed public key: [%v]", err) + } + + return &ecdsa.PublicKey{ + Curve: btcec.S256(), + X: parsedPublicKey.X, + Y: parsedPublicKey.Y, + } +} + +func testTaprootWalletMainUtxo( + t *testing.T, + bitcoinChain bitcoin.Chain, + walletPublicKey *ecdsa.PublicKey, +) *bitcoin.UnspentTransactionOutput { + t.Helper() + + walletXOnlyPublicKey, err := walletXOnlyPublicKey(walletPublicKey) + if err != nil { + t.Fatalf("cannot extract wallet x-only public key: [%v]", err) + } + + taprootScript, err := bitcoin.PayToTaproot(walletXOnlyPublicKey) + if err != nil { + t.Fatalf("cannot compute Taproot wallet script: [%v]", err) + } + + var previousTxHash bitcoin.Hash + previousTxHash[0] = 0x01 + + fundingTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: previousTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: taprootScript, + }, + }, + } + + if err := bitcoinChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatalf("cannot broadcast Taproot wallet main UTXO transaction: [%v]", err) + } + + return &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTx.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } +} diff --git a/pkg/tbtc/taproot_wallet.go b/pkg/tbtc/taproot_wallet.go new file mode 100644 index 0000000000..51c4653cd3 --- /dev/null +++ b/pkg/tbtc/taproot_wallet.go @@ -0,0 +1,20 @@ +package tbtc + +import ( + "crypto/ecdsa" + "fmt" + + "github.com/keep-network/keep-core/pkg/internal/byteutils" +) + +func walletXOnlyPublicKey(walletPublicKey *ecdsa.PublicKey) ([32]byte, error) { + x, err := byteutils.LeftPadTo32Bytes(walletPublicKey.X.Bytes()) + if err != nil { + return [32]byte{}, fmt.Errorf("cannot encode wallet x-only key: [%w]", err) + } + + var result [32]byte + copy(result[:], x) + + return result, nil +} diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index fa009348b9..4b94b97259 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -2,6 +2,7 @@ package tbtc import ( "context" + "errors" "fmt" "runtime" "time" @@ -13,6 +14,7 @@ import ( "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-common/pkg/persistence" "github.com/keep-network/keep-core/pkg/clientinfo" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/sortition" @@ -96,6 +98,29 @@ type Config struct { PreParamsGenerationConcurrency int // Concurrency level for key-generation for tECDSA. KeyGenerationConcurrency int + // FrostSigningBackend selects the FROST signing backend implementation. + // Supported values are resolved by pkg/frost/signing.SetExecutionBackendByName. + // Empty value defaults to the transitional legacy bridge backend. + // `native` allows transitional legacy fallback when native cryptographic + // execution is unavailable. `ffi` requires native execution and does not + // allow fallback. + FrostSigningBackend string + // DisableLegacyECDSA skips legacy ECDSA wallet DKG handling and pre-params + // generation. This is intended for FROST-only deployments where wallet + // creation and signing are handled by the FROST registry and signer. + DisableLegacyECDSA bool + // DisableLegacySortitionPoolMonitoring skips monitoring and auto-joining + // the legacy ECDSA sortition pool. This is intended for FROST-only + // deployments where operators are authorized through FrostAllowlist and no + // longer have TokenStaking-backed ECDSA operator state. + DisableLegacySortitionPoolMonitoring bool + // DisableFrostSortitionPoolMonitoring skips monitoring and auto-joining the + // FROST sortition pool. FROST pool monitoring is enabled by default whenever + // FROST authorization is configured, independent of DisableLegacyECDSA, so + // that operators stay selectable for new FROST wallet DKG both during the + // ECDSA drain and after the legacy pool is retired. This flag is an opt-out + // for operators that manage FROST pool membership out of band. + DisableFrostSortitionPoolMonitoring bool } // Initialize kicks off the TBTC by initializing internal state, ensuring @@ -155,6 +180,11 @@ func Initialize( return fmt.Errorf("cannot set up TBTC node: [%v]", err) } + // Note: the FROST signing-backend guard runs inside newNode above (right + // after the backend is configured and before the legacy pre-params pool is + // started), so an invalid backend fails Initialize with no protocol or + // pre-params side effects. + err = node.runCoordinationLayer(ctx) if err != nil { return fmt.Errorf("cannot run coordination layer: [%w]", err) @@ -162,17 +192,34 @@ func Initialize( deduplicator := newDeduplicator() + if frostChain, ok := chain.(FrostDKGChain); ok { + initializeFrostDKGCoordinator(ctx, node, frostChain) + } + if clientInfo != nil { // only if client info endpoint is configured clientInfo.ObserveApplicationSource( "tbtc", map[string]clientinfo.Source{ "pre_params_count": func() float64 { + if node.dkgExecutor == nil { + return 0 + } + return float64(node.dkgExecutor.preParamsCount()) }, }, ) + // RFC-21 Phase 7.3 interactive FROST signing observability. These sources + // are inert (report zero) until the gated interactive path actually runs; + // registering them only exposes the counters to the scrape and does not + // activate any signing behavior, so it is safe regardless of the cutover + // gates. Without this, the counters increment internally but never reach + // Prometheus. + frostsigning.RegisterRoastRetryMetrics(clientInfo) + frostsigning.RegisterInteractiveSigningMetrics(clientInfo) + if perfMetrics == nil { perfMetrics = clientinfo.NewPerformanceMetrics(ctx, clientInfo) } @@ -198,147 +245,212 @@ func Initialize( ) } - err = sortition.MonitorPool( - ctx, - logger, - chain, - sortition.DefaultStatusCheckTick, - sortition.NewConjunctionPolicy( - sortition.NewBetaOperatorPolicy(chain, logger), - &enoughPreParamsInPoolPolicy{ - node: node, - config: config, - }, - ), - ) - if err != nil { - return fmt.Errorf( - "could not set up sortition pool monitoring: [%v]", - err, + // During the ECDSA->FROST migration an operator is a member of BOTH the + // legacy ECDSA and the FROST sortition pools at once (existing ECDSA wallets + // keep draining while FROST is live), so each pool needs its own monitor + // loop bound explicitly to that pool. A single hasFrostAuthorization()- + // switched loop would maintain only one pool -- and, once the legacy flag is + // disabled post-cutover, neither. Resolve a sortition.Chain bound explicitly + // to the legacy ECDSA pool; fall back to the chain itself for chains that do + // not expose the explicit view (e.g. test chains, which are not FROST-dual). + legacyECDSASortitionChain := sortition.Chain(chain) + if provider, ok := chain.(interface { + LegacyECDSASortitionChain() sortition.Chain + }); ok { + legacyECDSASortitionChain = provider.LegacyECDSASortitionChain() + } + + if shouldMonitorLegacySortitionPool(config) { + err = sortition.MonitorPool( + ctx, + logger, + legacyECDSASortitionChain, + sortition.DefaultStatusCheckTick, + sortition.NewConjunctionPolicy( + sortition.NewBetaOperatorPolicy(legacyECDSASortitionChain, logger), + &enoughPreParamsInPoolPolicy{ + node: node, + config: config, + }, + ), ) + if err != nil { + return fmt.Errorf( + "could not set up legacy ECDSA sortition pool monitoring: [%v]", + err, + ) + } + } else { + logger.Infof("legacy ECDSA sortition pool monitoring disabled") } - _ = chain.OnDKGStarted(func(event *DKGStartedEvent) { - go func() { - if ok := deduplicator.notifyDKGStarted( - event.Seed, - ); !ok { - logger.Infof( - "DKG started event with seed [0x%x] has been "+ - "already processed", - event.Seed, + // FROST sortition pool monitoring runs whenever FROST authorization is + // configured, independent of DisableLegacyECDSA, so operators stay selectable + // for new FROST wallet DKG during the drain AND after the legacy pool is + // retired. The FROST loop uses the beta-operator policy only: the ECDSA + // pre-params gate does not apply to FROST DKG. + if provider, ok := chain.(interface { + FrostSortitionChain() (sortition.Chain, bool) + }); ok { + if frostSortitionChain, frostConfigured := provider.FrostSortitionChain(); frostConfigured { + if config.DisableFrostSortitionPoolMonitoring { + logger.Infof("FROST sortition pool monitoring disabled") + } else { + err = sortition.MonitorPool( + ctx, + logger, + frostSortitionChain, + sortition.DefaultStatusCheckTick, + sortition.NewBetaOperatorPolicy(frostSortitionChain, logger), ) - return + if err != nil { + // Absence from the FROST pool is a recoverable, per-pool + // state: an operator may register for FROST after node start, + // or remain ECDSA-only during the drain. It must not abort + // node startup nor the legacy pool's monitoring. Other + // monitoring failures remain fatal. + if errors.Is(err, sortition.ErrOperatorUnknown) { + logger.Warnf( + "operator is not registered in the FROST sortition " + + "pool; FROST pool monitoring is inactive until the " + + "operator is registered and the node is restarted", + ) + } else { + return fmt.Errorf( + "could not set up FROST sortition pool monitoring: [%v]", + err, + ) + } + } } + } + } - confirmationBlock := event.BlockNumber + dkgStartedConfirmationBlocks - - logger.Infof( - "observed DKG started event with seed [0x%x] and "+ - "starting block [%v]; waiting for block [%v] to confirm", - event.Seed, - event.BlockNumber, - confirmationBlock, - ) - - err := node.waitForBlockHeight(ctx, confirmationBlock) - if err != nil { - logger.Errorf("failed to confirm DKG started event: [%v]", err) - return - } + if shouldRunLegacyECDSA(config) { + _ = chain.OnDKGStarted(func(event *DKGStartedEvent) { + go func() { + if ok := deduplicator.notifyDKGStarted( + event.Seed, + ); !ok { + logger.Infof( + "DKG started event with seed [0x%x] has been "+ + "already processed", + event.Seed, + ) + return + } - dkgState, err := chain.GetDKGState() - if err != nil { - logger.Errorf("failed to check DKG state: [%v]", err) - return - } + confirmationBlock := event.BlockNumber + dkgStartedConfirmationBlocks - if dkgState == AwaitingResult { - // Fetch all past DKG started events starting from one - // confirmation period before the original event's block. - // If there was a chain reorg, the event we received could be - // moved to a block with a lower number than the one - // we received. - pastEvents, err := chain.PastDKGStartedEvents( - &DKGStartedEventFilter{ - StartBlock: event.BlockNumber - dkgStartedConfirmationBlocks, - }, + logger.Infof( + "observed DKG started event with seed [0x%x] and "+ + "starting block [%v]; waiting for block [%v] to confirm", + event.Seed, + event.BlockNumber, + confirmationBlock, ) + + err := node.waitForBlockHeight(ctx, confirmationBlock) if err != nil { - logger.Errorf("failed to get past DKG started events: [%v]", err) + logger.Errorf("failed to confirm DKG started event: [%v]", err) return } - // Should not happen but just in case. - if len(pastEvents) == 0 { - logger.Errorf("no past DKG started events") + dkgState, err := chain.GetDKGState() + if err != nil { + logger.Errorf("failed to check DKG state: [%v]", err) return } - lastEvent := pastEvents[len(pastEvents)-1] - - logger.Infof( - "DKG started with seed [0x%x] at block [%v]", - lastEvent.Seed, - lastEvent.BlockNumber, - ) + if dkgState == AwaitingResult { + // Fetch all past DKG started events starting from one + // confirmation period before the original event's block. + // If there was a chain reorg, the event we received could be + // moved to a block with a lower number than the one + // we received. + pastEvents, err := chain.PastDKGStartedEvents( + &DKGStartedEventFilter{ + StartBlock: event.BlockNumber - dkgStartedConfirmationBlocks, + }, + ) + if err != nil { + logger.Errorf("failed to get past DKG started events: [%v]", err) + return + } + + // Should not happen but just in case. + if len(pastEvents) == 0 { + logger.Errorf("no past DKG started events") + return + } + + lastEvent := pastEvents[len(pastEvents)-1] + + logger.Infof( + "DKG started with seed [0x%x] at block [%v]", + lastEvent.Seed, + lastEvent.BlockNumber, + ) + + // The off-chain protocol should be started as close as possible + // to the current block or even further. Starting the off-chain + // protocol with a past block will likely cause a failure of the + // first attempt as the start block is used to synchronize + // the announcements and the state machine. Here we ensure + // a proper start point by delaying the execution by the + // confirmation period length. + node.joinDKGIfEligible( + lastEvent.Seed, + lastEvent.BlockNumber, + dkgStartedConfirmationBlocks, + ) + } else { + logger.Infof( + "DKG started event with seed [0x%x] and starting "+ + "block [%v] was not confirmed", + event.Seed, + event.BlockNumber, + ) + } + }() + }) - // The off-chain protocol should be started as close as possible - // to the current block or even further. Starting the off-chain - // protocol with a past block will likely cause a failure of the - // first attempt as the start block is used to synchronize - // the announcements and the state machine. Here we ensure - // a proper start point by delaying the execution by the - // confirmation period length. - node.joinDKGIfEligible( - lastEvent.Seed, - lastEvent.BlockNumber, - dkgStartedConfirmationBlocks, - ) - } else { - logger.Infof( - "DKG started event with seed [0x%x] and starting "+ - "block [%v] was not confirmed", + _ = chain.OnDKGResultSubmitted(func(event *DKGResultSubmittedEvent) { + go func() { + if ok := deduplicator.notifyDKGResultSubmitted( event.Seed, + event.ResultHash, event.BlockNumber, - ) - } - }() - }) + ); !ok { + logger.Warnf( + "Result with hash [0x%x] for DKG with seed [0x%x] "+ + "and starting block [%v] has been already processed", + event.ResultHash, + event.Seed, + event.BlockNumber, + ) + return + } - _ = chain.OnDKGResultSubmitted(func(event *DKGResultSubmittedEvent) { - go func() { - if ok := deduplicator.notifyDKGResultSubmitted( - event.Seed, - event.ResultHash, - event.BlockNumber, - ); !ok { - logger.Warnf( + logger.Infof( "Result with hash [0x%x] for DKG with seed [0x%x] "+ - "and starting block [%v] has been already processed", + "submitted at block [%v]", event.ResultHash, event.Seed, event.BlockNumber, ) - return - } - logger.Infof( - "Result with hash [0x%x] for DKG with seed [0x%x] "+ - "submitted at block [%v]", - event.ResultHash, - event.Seed, - event.BlockNumber, - ) - - node.validateDKG( - event.Seed, - event.BlockNumber, - event.Result, - event.ResultHash, - ) - }() - }) + node.validateDKG( + event.Seed, + event.BlockNumber, + event.Result, + event.ResultHash, + ) + }() + }) + } else { + logger.Infof("legacy ECDSA wallet DKG disabled") + } _ = chain.OnWalletClosed(func(event *WalletClosedEvent) { go func() { @@ -377,6 +489,15 @@ func Initialize( return nil } +func shouldMonitorLegacySortitionPool(config Config) bool { + return shouldRunLegacyECDSA(config) && + !config.DisableLegacySortitionPoolMonitoring +} + +func shouldRunLegacyECDSA(config Config) bool { + return !config.DisableLegacyECDSA +} + // enoughPreParamsInPoolPolicy is a policy that enforces the sufficient size // of the DKG pre-parameters pool before joining the sortition pool. type enoughPreParamsInPoolPolicy struct { diff --git a/pkg/tbtc/tbtc_test.go b/pkg/tbtc/tbtc_test.go new file mode 100644 index 0000000000..f5f6878896 --- /dev/null +++ b/pkg/tbtc/tbtc_test.go @@ -0,0 +1,31 @@ +package tbtc + +import "testing" + +func TestShouldMonitorLegacySortitionPool(t *testing.T) { + if !shouldMonitorLegacySortitionPool(Config{}) { + t.Fatal("expected legacy sortition pool monitoring to be enabled by default") + } + + if shouldMonitorLegacySortitionPool(Config{ + DisableLegacySortitionPoolMonitoring: true, + }) { + t.Fatal("expected legacy sortition pool monitoring to be disabled") + } + + if shouldMonitorLegacySortitionPool(Config{ + DisableLegacyECDSA: true, + }) { + t.Fatal("expected FROST-only mode to disable legacy sortition pool monitoring") + } +} + +func TestShouldRunLegacyECDSA(t *testing.T) { + if !shouldRunLegacyECDSA(Config{}) { + t.Fatal("expected legacy ECDSA to run by default") + } + + if shouldRunLegacyECDSA(Config{DisableLegacyECDSA: true}) { + t.Fatal("expected legacy ECDSA to be disabled") + } +} diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index 9ad8b7e06f..7c1d89e142 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -8,6 +8,8 @@ import ( "encoding/hex" "fmt" "math/big" + "os" + "strings" "sync" "time" @@ -17,11 +19,17 @@ import ( "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" "go.uber.org/zap" ) +type unsignedTransactionInputReference struct { + TxIDHex string + Vout uint32 +} + // WalletActionType represents actions types that can be performed by a wallet. type WalletActionType uint8 @@ -281,7 +289,20 @@ type walletSigningExecutor interface { ctx context.Context, messages []*big.Int, startBlock uint64, - ) ([]*tecdsa.Signature, error) + ) ([]*frost.Signature, error) +} + +type schnorrWalletSigningExecutor interface { + usesSchnorrSignatures() bool +} + +type taprootTweakedWalletSigningExecutor interface { + signBatchWithTaprootMerkleRoots( + ctx context.Context, + messages []*big.Int, + taprootMerkleRoots []*[32]byte, + startBlock uint64, + ) ([]*frost.Signature, error) } // walletTransactionExecutor is a component allowing to sign and broadcast @@ -295,6 +316,11 @@ type walletTransactionExecutor struct { waitForBlockFn waitForBlockFn } +var buildTaprootTxViaNativeSignerFn = buildTaprootTxViaNativeSigner +var nativeBuildTaprootTxSigningSubstitutionEnabledFn = nativeBuildTaprootTxSigningSubstitutionEnabled + +const nativeBuildTaprootTxSigningSubstitutionEnvVar = "KEEP_CORE_NATIVE_BUILDTX_SIGNING_SUBSTITUTION" + func newWalletTransactionExecutor( btcChain bitcoin.Chain, executingWallet wallet, @@ -318,6 +344,21 @@ func (wte *walletTransactionExecutor) signTransaction( signingStartBlock uint64, signingTimeoutBlock uint64, ) (*bitcoin.Transaction, error) { + // The native tbtc-signer BuildTaprootTx parity/substitution path applies + // only to Taproot transactions: the native builder is a Taproot builder, so + // running it for a legacy (non-Taproot) ECDSA redemption/sweep/moving-funds + // transaction is meaningless, and a hard error from it would otherwise fail + // the signing of that legacy transaction. FROST/Schnorr wallets sign + // exclusively all-Taproot transactions (enforced below). + if unsignedTx.HasOnlyTaprootKeyPathInputs() { + if err := wte.maybeSubstituteNativeBuildTaprootTx( + signTxLogger, + unsignedTx, + ); err != nil { + return nil, err + } + } + signTxLogger.Infof("computing transaction's sig hashes") sigHashes, err := unsignedTx.ComputeSignatureHashes() @@ -328,6 +369,20 @@ func (wte *walletTransactionExecutor) signTransaction( ) } + if unsignedTx.HasTaprootKeyPathInputs() && + !unsignedTx.HasOnlyTaprootKeyPathInputs() { + return nil, fmt.Errorf( + "cannot apply FROST signatures to mixed taproot and legacy inputs", + ) + } + + if wte.usesSchnorrSignatures() && + !unsignedTx.HasOnlyTaprootKeyPathInputs() { + return nil, fmt.Errorf( + "cannot apply FROST signatures to non-taproot transaction inputs", + ) + } + signTxLogger.Infof("signing transaction's sig hashes") signingCtx, cancelSigningCtx := withCancelOnBlock( @@ -337,11 +392,29 @@ func (wte *walletTransactionExecutor) signTransaction( ) defer cancelSigningCtx() - signatures, err := wte.signingExecutor.signBatch( - signingCtx, - sigHashes, - signingStartBlock, - ) + var signatures []*frost.Signature + taprootMerkleRoots := unsignedTx.TaprootKeyPathInputMerkleRoots() + if hasTaprootMerkleRoots(taprootMerkleRoots) { + tweakedSigningExecutor, ok := wte.signingExecutor.(taprootTweakedWalletSigningExecutor) + if !ok { + return nil, fmt.Errorf( + "taproot tweaked signing requires signer support", + ) + } + + signatures, err = tweakedSigningExecutor.signBatchWithTaprootMerkleRoots( + signingCtx, + sigHashes, + taprootMerkleRoots, + signingStartBlock, + ) + } else { + signatures, err = wte.signingExecutor.signBatch( + signingCtx, + sigHashes, + signingStartBlock, + ) + } if err != nil { return nil, fmt.Errorf( "error while signing transaction's sig hashes: [%v]", @@ -351,11 +424,36 @@ func (wte *walletTransactionExecutor) signTransaction( signTxLogger.Infof("applying transaction's signatures") + if unsignedTx.HasTaprootKeyPathInputs() { + containers := make( + []*bitcoin.SchnorrSignatureContainer, + len(signatures), + ) + for i, signature := range signatures { + containers[i] = &bitcoin.SchnorrSignatureContainer{ + Signature: signature.Serialize(), + } + } + + tx, err := unsignedTx.AddTaprootKeyPathSignatures(containers) + if err != nil { + return nil, fmt.Errorf( + "error while applying transaction's taproot key-path "+ + "signatures: [%v]", + err, + ) + } + + signTxLogger.Infof("transaction created successfully") + + return tx, nil + } + containers := make([]*bitcoin.SignatureContainer, len(signatures)) for i, signature := range signatures { containers[i] = &bitcoin.SignatureContainer{ - R: signature.R, - S: signature.S, + R: new(big.Int).SetBytes(signature.R[:]), + S: new(big.Int).SetBytes(signature.S[:]), PublicKey: wte.executingWallet.publicKey, } } @@ -373,6 +471,382 @@ func (wte *walletTransactionExecutor) signTransaction( return tx, nil } +func (wte *walletTransactionExecutor) usesSchnorrSignatures() bool { + executor, ok := wte.signingExecutor.(schnorrWalletSigningExecutor) + if !ok { + return false + } + + return executor.usesSchnorrSignatures() +} + +// maybeSubstituteNativeBuildTaprootTx runs the native tbtc-signer BuildTaprootTx +// parity/substitution path: it builds the unsigned transaction via the native +// signer and, when KEEP_CORE_NATIVE_BUILDTX_SIGNING_SUBSTITUTION is set, +// substitutes the Go-built transaction with the native one. In the default build +// the native builder is a no-op (returns an empty hex), so this is observational +// only. It must only be called for Taproot transactions (see signTransaction). +func (wte *walletTransactionExecutor) maybeSubstituteNativeBuildTaprootTx( + signTxLogger log.StandardLogger, + unsignedTx *bitcoin.TransactionBuilder, +) error { + substitutionEnabled := nativeBuildTaprootTxSigningSubstitutionEnabledFn() + + nativeUnsignedTxHex, err := buildTaprootTxViaNativeSignerFn(unsignedTx) + if err != nil { + return fmt.Errorf( + "error while building unsigned transaction with native tbtc-signer: [%w]", + err, + ) + } + + if nativeUnsignedTxHex == "" { + return nil + } + + signTxLogger.Debugf( + "received unsigned transaction from native tbtc-signer BuildTaprootTx [txHexLen:%d]", + len(nativeUnsignedTxHex), + ) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + signTxLogger, + nativeUnsignedTxHex, + unsignedTx.UnsignedTransaction(), + substitutionEnabled, + ) + if err != nil { + return fmt.Errorf( + "cannot process native BuildTaprootTx unsigned transaction for signing: [%v]", + err, + ) + } + + if nativeUnsignedTx != nil { + if err := unsignedTx.ReplaceUnsignedTransaction(nativeUnsignedTx); err != nil { + return fmt.Errorf( + "cannot substitute Go unsigned transaction with native BuildTaprootTx output: [%v]", + err, + ) + } + + signTxLogger.Infof( + "substituted Go unsigned transaction with native tbtc-signer BuildTaprootTx output", + ) + } + + return nil +} + +func hasTaprootMerkleRoots(taprootMerkleRoots []*[32]byte) bool { + for _, merkleRoot := range taprootMerkleRoots { + if merkleRoot != nil { + return true + } + } + + return false +} + +func nativeBuildTaprootTxSigningSubstitutionEnabled() bool { + switch strings.ToLower( + strings.TrimSpace( + os.Getenv(nativeBuildTaprootTxSigningSubstitutionEnvVar), + ), + ) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func evaluateNativeUnsignedTransactionForSigning( + signTxLogger log.StandardLogger, + nativeUnsignedTxHex string, + expectedTransaction *bitcoin.Transaction, + substitutionEnabled bool, +) (*bitcoin.Transaction, error) { + nativeUnsignedTx, err := decodeNativeUnsignedTransactionHex(nativeUnsignedTxHex) + if err != nil { + if substitutionEnabled { + return nil, err + } + + signTxLogger.Warnf( + "cannot compare native BuildTaprootTx unsigned transaction with Go builder state: [%v]", + err, + ) + return nil, nil + } + + diverges, divergenceReason, err := nativeUnsignedTransactionDivergesFromTransaction( + nativeUnsignedTx, + expectedTransaction, + ) + if err != nil { + if substitutionEnabled { + return nil, err + } + + signTxLogger.Warnf( + "cannot compare native BuildTaprootTx unsigned transaction with Go builder state: [%v]", + err, + ) + return nil, nil + } + + if diverges { + divergenceMessage := "native BuildTaprootTx unsigned transaction diverges from Go builder state" + if divergenceReason != "" { + divergenceMessage = fmt.Sprintf( + "%s: %s", + divergenceMessage, + divergenceReason, + ) + } + + if substitutionEnabled { + return nil, fmt.Errorf("%s", divergenceMessage) + } + + signTxLogger.Warnf(divergenceMessage) + } + + if substitutionEnabled { + return nativeUnsignedTx, nil + } + + return nil, nil +} + +func decodeNativeUnsignedTransactionHex( + nativeUnsignedTxHex string, +) (*bitcoin.Transaction, error) { + nativeUnsignedTxBytes, err := hex.DecodeString(nativeUnsignedTxHex) + if err != nil { + return nil, fmt.Errorf("cannot decode native tx hex: [%w]", err) + } + + nativeUnsignedTx := &bitcoin.Transaction{} + if err := nativeUnsignedTx.Deserialize(nativeUnsignedTxBytes); err != nil { + return nil, fmt.Errorf("cannot deserialize native tx bytes: [%w]", err) + } + + return nativeUnsignedTx, nil +} + +func nativeUnsignedTransactionDivergesFromTransaction( + nativeUnsignedTx *bitcoin.Transaction, + expectedTransaction *bitcoin.Transaction, +) (bool, string, error) { + actualShape, err := extractUnsignedTransactionShapeFromTransaction(nativeUnsignedTx) + if err != nil { + return false, "", err + } + + expectedShape, err := extractUnsignedTransactionShapeFromTransaction(expectedTransaction) + if err != nil { + return false, "", err + } + + if actualShape.Version != expectedShape.Version { + return true, fmt.Sprintf( + "version mismatch: expected [%d], got [%d]", + expectedShape.Version, + actualShape.Version, + ), nil + } + + if actualShape.Locktime != expectedShape.Locktime { + return true, fmt.Sprintf( + "locktime mismatch: expected [%d], got [%d]", + expectedShape.Locktime, + actualShape.Locktime, + ), nil + } + + if reason, diverges := unsignedTransactionInputReferencesDivergenceReason( + actualShape.InputReferences, + expectedShape.InputReferences, + ); diverges { + return true, reason, nil + } + + if reason, diverges := unsignedTransactionInputSequencesDivergenceReason( + actualShape.InputSequences, + expectedShape.InputSequences, + ); diverges { + return true, reason, nil + } + + if reason, diverges := unsignedTransactionOutputsDivergenceReason( + actualShape.Outputs, + expectedShape.Outputs, + ); diverges { + return true, reason, nil + } + + return false, "", nil +} + +func unsignedTransactionInputReferencesDivergenceReason( + actual []unsignedTransactionInputReference, + expected []unsignedTransactionInputReference, +) (string, bool) { + if len(actual) != len(expected) { + return fmt.Sprintf( + "input reference count mismatch: expected [%d], got [%d]", + len(expected), + len(actual), + ), true + } + + for i := range actual { + if actual[i] != expected[i] { + return fmt.Sprintf( + "input reference mismatch at index [%d]: expected [%s:%d], got [%s:%d]", + i, + expected[i].TxIDHex, + expected[i].Vout, + actual[i].TxIDHex, + actual[i].Vout, + ), true + } + } + + return "", false +} + +type unsignedTransactionShape struct { + Version int32 + Locktime uint32 + InputReferences []unsignedTransactionInputReference + InputSequences []uint32 + Outputs []bitcoin.UnsignedTransactionOutput +} + +func extractUnsignedTransactionShapeFromTransaction( + transaction *bitcoin.Transaction, +) (*unsignedTransactionShape, error) { + if transaction == nil { + return nil, fmt.Errorf("transaction is nil") + } + + inputReferences := make( + []unsignedTransactionInputReference, + 0, + len(transaction.Inputs), + ) + inputSequences := make([]uint32, 0, len(transaction.Inputs)) + for i, input := range transaction.Inputs { + if input == nil { + return nil, fmt.Errorf("transaction input [%d] is nil", i) + } + + if input.Outpoint == nil { + return nil, fmt.Errorf("transaction input [%d] outpoint is nil", i) + } + + inputReferences = append( + inputReferences, + unsignedTransactionInputReference{ + TxIDHex: input.Outpoint.TransactionHash.Hex(bitcoin.ReversedByteOrder), + Vout: input.Outpoint.OutputIndex, + }, + ) + inputSequences = append(inputSequences, input.Sequence) + } + + outputs := make([]bitcoin.UnsignedTransactionOutput, 0, len(transaction.Outputs)) + for i, output := range transaction.Outputs { + if output == nil { + return nil, fmt.Errorf("transaction output [%d] is nil", i) + } + + if output.Value < 0 { + return nil, fmt.Errorf("transaction output [%d] value is negative", i) + } + + outputs = append( + outputs, + bitcoin.UnsignedTransactionOutput{ + ScriptPubKeyHex: hex.EncodeToString(output.PublicKeyScript), + ValueSats: uint64(output.Value), + }, + ) + } + + return &unsignedTransactionShape{ + Version: transaction.Version, + Locktime: transaction.Locktime, + InputReferences: inputReferences, + InputSequences: inputSequences, + Outputs: outputs, + }, nil +} + +func unsignedTransactionOutputsDivergenceReason( + actual []bitcoin.UnsignedTransactionOutput, + expected []bitcoin.UnsignedTransactionOutput, +) (string, bool) { + if len(actual) != len(expected) { + return fmt.Sprintf( + "output count mismatch: expected [%d], got [%d]", + len(expected), + len(actual), + ), true + } + + for i := range actual { + if actual[i].ValueSats != expected[i].ValueSats { + return fmt.Sprintf( + "output value mismatch at index [%d]: expected [%d], got [%d]", + i, + expected[i].ValueSats, + actual[i].ValueSats, + ), true + } + + if actual[i].ScriptPubKeyHex != expected[i].ScriptPubKeyHex { + return fmt.Sprintf( + "output script mismatch at index [%d]: expected [%s], got [%s]", + i, + expected[i].ScriptPubKeyHex, + actual[i].ScriptPubKeyHex, + ), true + } + } + + return "", false +} + +func unsignedTransactionInputSequencesDivergenceReason( + actual []uint32, + expected []uint32, +) (string, bool) { + if len(actual) != len(expected) { + return fmt.Sprintf( + "input sequence count mismatch: expected [%d], got [%d]", + len(expected), + len(actual), + ), true + } + + for i := range actual { + if actual[i] != expected[i] { + return fmt.Sprintf( + "input sequence mismatch at index [%d]: expected [%d], got [%d]", + i, + expected[i], + actual[i], + ), true + } + } + + return "", false +} + // broadcastTransaction broadcasts a signed Bitcoin transaction until // the transaction lands in the Bitcoin mempool or the provided timeout // is hit, whichever comes first. @@ -519,6 +993,48 @@ func DetermineWalletMainUtxo( walletPublicKeyHash [20]byte, bridgeChain BridgeChain, btcChain bitcoin.Chain, +) (*bitcoin.UnspentTransactionOutput, error) { + walletScripts, err := legacyWalletPublicKeyScripts(walletPublicKeyHash) + if err != nil { + return nil, err + } + + return determineWalletMainUtxo( + walletPublicKeyHash, + walletScripts, + bridgeChain, + btcChain, + ) +} + +// DetermineWalletMainUtxoForPublicKey determines the plain-text wallet main +// UTXO currently registered in the Bridge on-chain contract. Unlike +// DetermineWalletMainUtxo, this variant can discover Taproot wallet outputs. +func DetermineWalletMainUtxoForPublicKey( + walletPublicKey *ecdsa.PublicKey, + bridgeChain BridgeChain, + btcChain bitcoin.Chain, +) (*bitcoin.UnspentTransactionOutput, error) { + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + + walletScripts, err := walletPublicKeyScripts(walletPublicKey) + if err != nil { + return nil, err + } + + return determineWalletMainUtxo( + walletPublicKeyHash, + walletScripts, + bridgeChain, + btcChain, + ) +} + +func determineWalletMainUtxo( + walletPublicKeyHash [20]byte, + walletScripts []bitcoin.Script, + bridgeChain BridgeChain, + btcChain bitcoin.Chain, ) (*bitcoin.UnspentTransactionOutput, error) { walletChainData, err := bridgeChain.GetWallet(walletPublicKeyHash) if err != nil { @@ -543,20 +1059,15 @@ func DetermineWalletMainUtxo( // fetch full transaction data (time-consuming calls) starting from // the most recent transactions as there is a high chance the main UTXO // comes from there. - txHashes, err := btcChain.GetTxHashesForPublicKeyHash(walletPublicKeyHash) + txHashes, err := getTxHashesForWalletScripts( + btcChain, + walletPublicKeyHash, + walletScripts, + ) if err != nil { return nil, fmt.Errorf("cannot get transactions history for wallet: [%v]", err) } - walletP2PKH, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) - if err != nil { - return nil, fmt.Errorf("cannot construct P2PKH for wallet: [%v]", err) - } - walletP2WPKH, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) - if err != nil { - return nil, fmt.Errorf("cannot construct P2WPKH for wallet: [%v]", err) - } - // Start iterating from the latest transaction as the chance it matches // the wallet main UTXO is the highest. for i := len(txHashes) - 1; i >= 0; i-- { @@ -575,8 +1086,7 @@ func DetermineWalletMainUtxo( // the wallet public key hash. for outputIndex, output := range transaction.Outputs { script := output.PublicKeyScript - matchesWallet := bytes.Equal(script, walletP2PKH) || - bytes.Equal(script, walletP2WPKH) + matchesWallet := scriptMatchesAny(script, walletScripts) // Once the right output is found, check whether their hash // matches the main UTXO hash stored on-chain. If so, this @@ -608,11 +1118,63 @@ func EnsureWalletSyncedBetweenChains( walletMainUtxo *bitcoin.UnspentTransactionOutput, bridgeChain BridgeChain, btcChain bitcoin.Chain, +) error { + walletScripts, err := legacyWalletPublicKeyScripts(walletPublicKeyHash) + if err != nil { + return err + } + + return ensureWalletSyncedBetweenChains( + walletPublicKeyHash, + walletScripts, + walletMainUtxo, + bridgeChain, + btcChain, + ) +} + +// EnsureWalletSyncedBetweenChainsForPublicKey makes sure all actions taken by +// the wallet on the Bitcoin chain are reflected in the host chain Bridge. +// Unlike EnsureWalletSyncedBetweenChains, this variant can discover Taproot +// wallet outputs. +func EnsureWalletSyncedBetweenChainsForPublicKey( + walletPublicKey *ecdsa.PublicKey, + walletMainUtxo *bitcoin.UnspentTransactionOutput, + bridgeChain BridgeChain, + btcChain bitcoin.Chain, +) error { + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + + walletScripts, err := walletPublicKeyScripts(walletPublicKey) + if err != nil { + return err + } + + return ensureWalletSyncedBetweenChains( + walletPublicKeyHash, + walletScripts, + walletMainUtxo, + bridgeChain, + btcChain, + ) +} + +func ensureWalletSyncedBetweenChains( + walletPublicKeyHash [20]byte, + walletScripts []bitcoin.Script, + walletMainUtxo *bitcoin.UnspentTransactionOutput, + bridgeChain BridgeChain, + btcChain bitcoin.Chain, ) error { // Take UTXOs controlled by the wallet on Bitcoin chain. Those are outputs // coming from confirmed transactions, ready to be spent right now, and // not used as inputs of other (either confirmed or mempool) transactions. - confirmedUtxos, err := btcChain.GetUtxosForPublicKeyHash(walletPublicKeyHash) + confirmedUtxos, err := getUtxosForWalletScripts( + btcChain, + walletPublicKeyHash, + walletScripts, + true, + ) if err != nil { return fmt.Errorf("cannot get confirmed UTXOs: [%v]", err) } @@ -659,7 +1221,12 @@ func EnsureWalletSyncedBetweenChains( // to the wallet address. We need to look at the confirmed and mempool // UTXOs and make sure there are no transactions produced by the wallet // there. - mempoolUtxos, err := btcChain.GetMempoolUtxosForPublicKeyHash(walletPublicKeyHash) + mempoolUtxos, err := getUtxosForWalletScripts( + btcChain, + walletPublicKeyHash, + walletScripts, + false, + ) if err != nil { return fmt.Errorf("cannot get mempool UTXOs: [%v]", err) } @@ -766,6 +1333,100 @@ func EnsureWalletSyncedBetweenChains( } } +type walletPublicKeyScriptsChain interface { + GetTxHashesForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, + ) ([]bitcoin.Hash, error) + GetUtxosForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, + ) ([]*bitcoin.UnspentTransactionOutput, error) + GetMempoolUtxosForPublicKeyScripts( + publicKeyScripts []bitcoin.Script, + ) ([]*bitcoin.UnspentTransactionOutput, error) +} + +func legacyWalletPublicKeyScripts( + walletPublicKeyHash [20]byte, +) ([]bitcoin.Script, error) { + walletP2PKH, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf("cannot construct P2PKH for wallet: [%v]", err) + } + + walletP2WPKH, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf("cannot construct P2WPKH for wallet: [%v]", err) + } + + return []bitcoin.Script{walletP2PKH, walletP2WPKH}, nil +} + +func walletPublicKeyScripts( + walletPublicKey *ecdsa.PublicKey, +) ([]bitcoin.Script, error) { + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + + walletScripts, err := legacyWalletPublicKeyScripts(walletPublicKeyHash) + if err != nil { + return nil, err + } + + xOnlyPublicKey, err := walletXOnlyPublicKey(walletPublicKey) + if err != nil { + return nil, err + } + + walletP2TR, err := bitcoin.PayToTaproot(xOnlyPublicKey) + if err != nil { + return nil, fmt.Errorf("cannot construct P2TR for wallet: [%v]", err) + } + + return append(walletScripts, walletP2TR), nil +} + +func getTxHashesForWalletScripts( + btcChain bitcoin.Chain, + walletPublicKeyHash [20]byte, + walletScripts []bitcoin.Script, +) ([]bitcoin.Hash, error) { + if scriptChain, ok := btcChain.(walletPublicKeyScriptsChain); ok { + return scriptChain.GetTxHashesForPublicKeyScripts(walletScripts) + } + + return btcChain.GetTxHashesForPublicKeyHash(walletPublicKeyHash) +} + +func getUtxosForWalletScripts( + btcChain bitcoin.Chain, + walletPublicKeyHash [20]byte, + walletScripts []bitcoin.Script, + confirmed bool, +) ([]*bitcoin.UnspentTransactionOutput, error) { + if scriptChain, ok := btcChain.(walletPublicKeyScriptsChain); ok { + if confirmed { + return scriptChain.GetUtxosForPublicKeyScripts(walletScripts) + } + + return scriptChain.GetMempoolUtxosForPublicKeyScripts(walletScripts) + } + + if confirmed { + return btcChain.GetUtxosForPublicKeyHash(walletPublicKeyHash) + } + + return btcChain.GetMempoolUtxosForPublicKeyHash(walletPublicKeyHash) +} + +func scriptMatchesAny(script bitcoin.Script, scripts []bitcoin.Script) bool { + for _, candidate := range scripts { + if bytes.Equal(script, candidate) { + return true + } + } + + return false +} + // signer represents a threshold signer of a tBTC wallet. A signer holds // a wallet tECDSA private key share and is able to participate in the // signing process. @@ -785,6 +1446,10 @@ type signer struct { // privateKeyShare is the tECDSA private key share required to participate // in the signing process. privateKeyShare *tecdsa.PrivateKeyShare + + // signerMaterial carries backend-specific signer material used by the + // FROST signing runtime. Legacy path falls back to privateKeyShare. + signerMaterial any } // newSigner constructs a new instance of the wallet's signer. @@ -793,19 +1458,33 @@ func newSigner( walletSigningGroupOperators []chain.Address, signingGroupMemberIndex group.MemberIndex, privateKeyShare *tecdsa.PrivateKeyShare, + signerMaterial any, ) *signer { wallet := wallet{ publicKey: walletPublicKey, signingGroupOperators: walletSigningGroupOperators, } + if signerMaterial == nil { + signerMaterial = privateKeyShare + } + return &signer{ wallet: wallet, signingGroupMemberIndex: signingGroupMemberIndex, privateKeyShare: privateKeyShare, + signerMaterial: signerMaterial, } } +func (s *signer) signingMaterial() any { + if s.signerMaterial != nil { + return s.signerMaterial + } + + return s.privateKeyShare +} + func (s *signer) String() string { return fmt.Sprintf( "signer with index [%v] of wallet [%s]", diff --git a/pkg/tbtc/wallet_id.go b/pkg/tbtc/wallet_id.go new file mode 100644 index 0000000000..6605b762fe --- /dev/null +++ b/pkg/tbtc/wallet_id.go @@ -0,0 +1,30 @@ +package tbtc + +// DeriveLegacyWalletID derives the canonical bridge wallet ID for legacy +// ECDSA wallets from their 20-byte wallet public key hash. +// +// Legacy wallet ID format is a left-padded bytes20 hash: +// bytes32(uint256(uint160(walletPubKeyHash))). +func DeriveLegacyWalletID(walletPublicKeyHash [20]byte) [32]byte { + var walletID [32]byte + copy(walletID[12:], walletPublicKeyHash[:]) + return walletID +} + +// WalletPublicKeyHashFromLegacyWalletID extracts the compatibility wallet +// public key hash from a canonical legacy wallet ID. +// +// Legacy wallet ID format is a left-padded bytes20 hash: +// bytes32(uint256(uint160(walletPubKeyHash))). +func WalletPublicKeyHashFromLegacyWalletID(walletID [32]byte) ([20]byte, bool) { + for i := 0; i < 12; i++ { + if walletID[i] != 0 { + return [20]byte{}, false + } + } + + var walletPublicKeyHash [20]byte + copy(walletPublicKeyHash[:], walletID[12:]) + + return walletPublicKeyHash, true +} diff --git a/pkg/tbtc/wallet_id_from_signer_default.go b/pkg/tbtc/wallet_id_from_signer_default.go new file mode 100644 index 0000000000..7f945cb208 --- /dev/null +++ b/pkg/tbtc/wallet_id_from_signer_default.go @@ -0,0 +1,12 @@ +//go:build !frost_native + +package tbtc + +import "crypto/ecdsa" + +func calculateWalletIDForSigner( + signer *signer, + calculateLegacyWalletID func(*ecdsa.PublicKey) ([32]byte, error), +) ([32]byte, error) { + return calculateLegacyWalletID(signer.wallet.publicKey) +} diff --git a/pkg/tbtc/wallet_id_from_signer_frost_native.go b/pkg/tbtc/wallet_id_from_signer_frost_native.go new file mode 100644 index 0000000000..e19da61aa6 --- /dev/null +++ b/pkg/tbtc/wallet_id_from_signer_frost_native.go @@ -0,0 +1,103 @@ +//go:build frost_native + +package tbtc + +import ( + "crypto/ecdsa" + "encoding/json" + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func calculateWalletIDForSigner( + signer *signer, + calculateLegacyWalletID func(*ecdsa.PublicKey) ([32]byte, error), +) ([32]byte, error) { + if signer == nil { + return [32]byte{}, fmt.Errorf("signer is nil") + } + + walletID, isFrostWallet, err := frostWalletIDFromSigner(signer) + if err != nil { + return [32]byte{}, err + } + if isFrostWallet { + return walletID, nil + } + + return calculateLegacyWalletID(signer.wallet.publicKey) +} + +func frostWalletIDFromSigner(signer *signer) ([32]byte, bool, error) { + material, ok := nativeSignerMaterialFromSigner(signer) + if !ok { + return [32]byte{}, false, nil + } + + switch material.Format { + case frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1: + case frostsigning.NativeSignerMaterialFormatFrostUniFFIV2: + return [32]byte{}, false, fmt.Errorf( + "%w: unsupported UniFFI FROST signer material format [%s]; "+ + "it cannot sweep Taproot deposits; use [%s]", + frostsigning.ErrUnsupportedSignerMaterialFormat, + frostsigning.NativeSignerMaterialFormatFrostUniFFIV2, + frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + ) + default: + return [32]byte{}, false, nil + } + + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(material.Payload, &payload); err != nil { + return [32]byte{}, false, fmt.Errorf( + "cannot decode FrostTBTCSignerV1 signer material: [%w]", + err, + ) + } + + if payload.KeyGroupSource == + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey { + return [32]byte{}, false, nil + } + + xOnlyOutputKey, err := frostsigning.ExtractTaprootOutputKeyFromMaterial( + material, + ) + if err != nil { + return [32]byte{}, true, err + } + if len(xOnlyOutputKey) != 32 { + return [32]byte{}, true, fmt.Errorf( + "FROST DKG output key length [%d] is not 32", + len(xOnlyOutputKey), + ) + } + + var walletID [32]byte + copy(walletID[:], xOnlyOutputKey) + + return walletID, true, nil +} + +func nativeSignerMaterialFromSigner( + signer *signer, +) (*frostsigning.NativeSignerMaterial, bool) { + if signer == nil { + return nil, false + } + + switch material := signer.signingMaterial().(type) { + case *frostsigning.NativeSignerMaterial: + if material == nil { + return nil, false + } + + return material, true + case frostsigning.NativeSignerMaterial: + return &material, true + default: + return nil, false + } +} diff --git a/pkg/tbtc/wallet_id_from_signer_frost_native_test.go b/pkg/tbtc/wallet_id_from_signer_frost_native_test.go new file mode 100644 index 0000000000..e3d358ae92 --- /dev/null +++ b/pkg/tbtc/wallet_id_from_signer_frost_native_test.go @@ -0,0 +1,150 @@ +//go:build frost_native + +package tbtc + +import ( + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "errors" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func TestCalculateWalletIDForSigner_FrostUniFFIV2RejectsUnsupportedMaterial(t *testing.T) { + payload, err := json.Marshal(struct { + KeyPackage *frostsigning.NativeFROSTKeyPackage `json:"keyPackage"` + PublicKeyPackage *frostsigning.NativeFROSTPublicKeyPackage `json:"publicKeyPackage"` + }{ + KeyPackage: &frostsigning.NativeFROSTKeyPackage{ + Identifier: "member-1", + Data: []byte{0x01}, + }, + PublicKeyPackage: &frostsigning.NativeFROSTPublicKeyPackage{ + VerifyingKey: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }) + if err != nil { + t.Fatalf("unexpected payload marshal error: [%v]", err) + } + + signer := createMockSigner(t) + signer.signerMaterial = &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + } + + legacyCalculatorCalled := false + _, err = calculateWalletIDForSigner( + signer, + func(_ *ecdsa.PublicKey) ([32]byte, error) { + legacyCalculatorCalled = true + return [32]byte{0xff}, nil + }, + ) + if err == nil { + t.Fatal("expected unsupported material error") + } + if !errors.Is(err, frostsigning.ErrUnsupportedSignerMaterialFormat) { + t.Fatalf( + "unexpected wallet ID calculation error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrUnsupportedSignerMaterialFormat, + err, + ) + } + if legacyCalculatorCalled { + t.Fatal("legacy wallet ID calculator should not have been called") + } +} + +func TestCalculateWalletIDForSigner_TBTCSignerDkgPersistedUsesXOnlyOutputKey( + t *testing.T, +) { + const xOnlyOutputKey = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: xOnlyOutputKey, + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceDKGPersisted, + }) + if err != nil { + t.Fatalf("unexpected payload marshal error: [%v]", err) + } + + signer := createMockSigner(t) + signer.signerMaterial = &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + + legacyCalculatorCalled := false + walletID, err := calculateWalletIDForSigner( + signer, + func(_ *ecdsa.PublicKey) ([32]byte, error) { + legacyCalculatorCalled = true + return [32]byte{0xff}, nil + }, + ) + if err != nil { + t.Fatalf("unexpected wallet ID calculation error: [%v]", err) + } + if legacyCalculatorCalled { + t.Fatal("legacy wallet ID calculator should not have been called") + } + + var expectedWalletID [32]byte + expectedBytes, err := hex.DecodeString(xOnlyOutputKey) + if err != nil { + t.Fatalf("unexpected hex decode error: [%v]", err) + } + copy(expectedWalletID[:], expectedBytes) + + if walletID != expectedWalletID { + t.Fatalf( + "unexpected FROST wallet ID\nexpected: [0x%x]\nactual: [0x%x]", + expectedWalletID, + walletID, + ) + } +} + +func TestCalculateWalletIDForSigner_TBTCSignerLegacyKeyGroupSourceUsesLegacyWalletID( + t *testing.T, +) { + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: "legacy-key-group", + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + }) + if err != nil { + t.Fatalf("unexpected payload marshal error: [%v]", err) + } + + signer := createMockSigner(t) + signer.signerMaterial = &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + + expectedWalletID := [32]byte{0x11, 0x22, 0x33} + legacyCalculatorCalled := false + walletID, err := calculateWalletIDForSigner( + signer, + func(_ *ecdsa.PublicKey) ([32]byte, error) { + legacyCalculatorCalled = true + return expectedWalletID, nil + }, + ) + if err != nil { + t.Fatalf("unexpected wallet ID calculation error: [%v]", err) + } + if !legacyCalculatorCalled { + t.Fatal("legacy wallet ID calculator was not called") + } + if walletID != expectedWalletID { + t.Fatalf( + "unexpected legacy wallet ID\nexpected: [0x%x]\nactual: [0x%x]", + expectedWalletID, + walletID, + ) + } +} diff --git a/pkg/tbtc/wallet_id_test.go b/pkg/tbtc/wallet_id_test.go new file mode 100644 index 0000000000..eb6ee3688e --- /dev/null +++ b/pkg/tbtc/wallet_id_test.go @@ -0,0 +1,89 @@ +package tbtc + +import ( + "encoding/hex" + "testing" +) + +func TestDeriveLegacyWalletID(t *testing.T) { + walletPublicKeyHashBytes, err := hex.DecodeString( + "e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode wallet public key hash: [%v]", err) + } + + var walletPublicKeyHash [20]byte + copy(walletPublicKeyHash[:], walletPublicKeyHashBytes) + + expectedWalletIDBytes, err := hex.DecodeString( + "000000000000000000000000e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode expected wallet ID: [%v]", err) + } + + var expectedWalletID [32]byte + copy(expectedWalletID[:], expectedWalletIDBytes) + + actualWalletID := DeriveLegacyWalletID(walletPublicKeyHash) + if actualWalletID != expectedWalletID { + t.Fatalf( + "unexpected wallet ID\nexpected: [%x]\nactual: [%x]", + expectedWalletID, + actualWalletID, + ) + } +} + +func TestWalletPublicKeyHashFromLegacyWalletID(t *testing.T) { + walletIDBytes, err := hex.DecodeString( + "000000000000000000000000e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode wallet ID: [%v]", err) + } + + var walletID [32]byte + copy(walletID[:], walletIDBytes) + + expectedWalletPublicKeyHashBytes, err := hex.DecodeString( + "e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode expected wallet public key hash: [%v]", err) + } + + var expectedWalletPublicKeyHash [20]byte + copy(expectedWalletPublicKeyHash[:], expectedWalletPublicKeyHashBytes) + + actualWalletPublicKeyHash, ok := WalletPublicKeyHashFromLegacyWalletID(walletID) + if !ok { + t.Fatal("expected wallet ID to be recognized as legacy") + } + + if actualWalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualWalletPublicKeyHash, + ) + } +} + +func TestWalletPublicKeyHashFromLegacyWalletID_NonLegacy(t *testing.T) { + walletIDBytes, err := hex.DecodeString( + "010000000000000000000000e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode wallet ID: [%v]", err) + } + + var walletID [32]byte + copy(walletID[:], walletIDBytes) + + _, ok := WalletPublicKeyHashFromLegacyWalletID(walletID) + if ok { + t.Fatal("expected wallet ID to be recognized as non-legacy") + } +} diff --git a/pkg/tbtc/wallet_output_script.go b/pkg/tbtc/wallet_output_script.go new file mode 100644 index 0000000000..3048382eee --- /dev/null +++ b/pkg/tbtc/wallet_output_script.go @@ -0,0 +1,78 @@ +package tbtc + +import ( + "bytes" + "fmt" + + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +// WalletOutputScript constructs the Bitcoin locking script for the canonical +// wallet identity. Legacy ECDSA wallets keep using P2WPKH outputs. Native FROST +// wallets use their canonical wallet ID as the P2TR output key. +func WalletOutputScript( + walletPublicKeyHash [20]byte, + walletID [32]byte, +) (bitcoin.Script, error) { + if walletID == [32]byte{} { + walletID = DeriveLegacyWalletID(walletPublicKeyHash) + } + + if legacyWalletPublicKeyHash, ok := WalletPublicKeyHashFromLegacyWalletID( + walletID, + ); ok { + if !bytes.Equal( + legacyWalletPublicKeyHash[:], + walletPublicKeyHash[:], + ) { + return nil, fmt.Errorf( + "legacy wallet ID does not match wallet public key hash", + ) + } + + return bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + } + + return bitcoin.PayToTaproot(walletID) +} + +type walletDataResolver interface { + GetWallet(walletPublicKeyHash [20]byte) (*WalletChainData, error) +} + +func walletOutputScriptForPublicKeyHash( + chain walletDataResolver, + walletPublicKeyHash [20]byte, +) (bitcoin.Script, error) { + walletChainData, err := chain.GetWallet(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf("cannot get wallet's chain data: [%w]", err) + } + + return WalletOutputScript(walletPublicKeyHash, walletChainData.WalletID) +} + +func walletOutputScriptsForPublicKeyHashes( + chain walletDataResolver, + walletPublicKeyHashes [][20]byte, +) ([]bitcoin.Script, error) { + outputScripts := make([]bitcoin.Script, len(walletPublicKeyHashes)) + + for i, walletPublicKeyHash := range walletPublicKeyHashes { + outputScript, err := walletOutputScriptForPublicKeyHash( + chain, + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot compute output script for wallet [%d]: [%w]", + i, + err, + ) + } + + outputScripts[i] = outputScript + } + + return outputScripts, nil +} diff --git a/pkg/tbtc/wallet_output_script_test.go b/pkg/tbtc/wallet_output_script_test.go new file mode 100644 index 0000000000..9854effc0f --- /dev/null +++ b/pkg/tbtc/wallet_output_script_test.go @@ -0,0 +1,81 @@ +package tbtc + +import ( + "bytes" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +func TestWalletOutputScript_LegacyWalletID(t *testing.T) { + walletPublicKeyHash := hexToByte20("c7302d75072d78be94eb8d36c4b77583c7abb06e") + walletID := DeriveLegacyWalletID(walletPublicKeyHash) + + actualScript, err := WalletOutputScript(walletPublicKeyHash, walletID) + if err != nil { + t.Fatal(err) + } + + expectedScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(actualScript, expectedScript) { + t.Fatalf("unexpected legacy wallet script: [%x]", actualScript) + } +} + +func TestWalletOutputScript_ZeroWalletIDFallsBackToLegacy(t *testing.T) { + walletPublicKeyHash := hexToByte20("c7302d75072d78be94eb8d36c4b77583c7abb06e") + + actualScript, err := WalletOutputScript(walletPublicKeyHash, [32]byte{}) + if err != nil { + t.Fatal(err) + } + + expectedScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(actualScript, expectedScript) { + t.Fatalf("unexpected zero-ID wallet script: [%x]", actualScript) + } +} + +func TestWalletOutputScript_FrostWalletID(t *testing.T) { + walletPublicKeyHash := hexToByte20("c7302d75072d78be94eb8d36c4b77583c7abb06e") + walletID := [32]byte{ + 0x23, 0x36, 0xf6, 0x50, 0x04, 0xd8, 0xf1, 0x22, + 0xf1, 0xfe, 0x94, 0x7e, 0xbd, 0x00, 0x9a, 0x8b, + 0x4a, 0xdd, 0x3a, 0x0d, 0x93, 0x73, 0x56, 0xd5, + 0x68, 0xe3, 0x0f, 0x7f, 0xcc, 0x2e, 0x40, 0x08, + } + + actualScript, err := WalletOutputScript(walletPublicKeyHash, walletID) + if err != nil { + t.Fatal(err) + } + + expectedScript, err := bitcoin.PayToTaproot(walletID) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(actualScript, expectedScript) { + t.Fatalf("unexpected FROST wallet script: [%x]", actualScript) + } +} + +func TestWalletOutputScript_LegacyWalletIDMismatch(t *testing.T) { + walletPublicKeyHash := hexToByte20("c7302d75072d78be94eb8d36c4b77583c7abb06e") + walletID := DeriveLegacyWalletID( + hexToByte20("3091d288521caec06ea912eacfd733edc5a36d6e"), + ) + + _, err := WalletOutputScript(walletPublicKeyHash, walletID) + if err == nil { + t.Fatal("expected legacy wallet ID mismatch error") + } +} diff --git a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go new file mode 100644 index 0000000000..e93b849ac7 --- /dev/null +++ b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go @@ -0,0 +1,1536 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "testing" + + btcec2 "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestWalletTransactionExecutor_SignTransaction_ReturnsBuildTaprootTxError( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + + original := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = original + }) + + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", errors.New("build tx failed") + } + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: &unexpectedSigningExecutorForBuildTaprootTxError{}, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + logger := &warningCaptureLogger{} + + _, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err == nil { + t.Fatal("expected signTransaction error") + } + + if !strings.Contains(err.Error(), "native tbtc-signer") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestWalletTransactionExecutor_SignTransaction_PropagatesBuildTaprootTxBridgeOperationError( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + + original := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = original + }) + + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", fmt.Errorf( + "%w: operation failed", + frostsigning.ErrNativeBridgeOperationFailed, + ) + } + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: &unexpectedSigningExecutorForBuildTaprootTxError{}, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + logger := &warningCaptureLogger{} + + _, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err == nil { + t.Fatal("expected signTransaction error") + } + + if !errors.Is(err, frostsigning.ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected bridge operation failure error: [%v], got [%v]", + frostsigning.ErrNativeBridgeOperationFailed, + err, + ) + } + + if !strings.Contains(err.Error(), "native tbtc-signer") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_ObservationalModeLogsWarning( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 999, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + }, + false, + ) + if err != nil { + t.Fatalf("unexpected evaluation error: [%v]", err) + } + + if nativeUnsignedTx != nil { + t.Fatal("did not expect native transaction substitution in observational mode") + } + + if len(logger.warningMessages) != 1 { + t.Fatalf( + "unexpected warning message count\nexpected: [%v]\nactual: [%v]", + 1, + len(logger.warningMessages), + ) + } + + if !strings.Contains(logger.warningMessages[0], "diverges") { + t.Fatalf("unexpected warning message: [%v]", logger.warningMessages[0]) + } + + if !strings.Contains(logger.warningMessages[0], "output value mismatch") { + t.Fatalf("missing divergence detail in warning: [%v]", logger.warningMessages[0]) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_ObservationalModeLogsWarningOnStructuralDivergence( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + }, + false, + ) + if err != nil { + t.Fatalf("unexpected evaluation error: [%v]", err) + } + + if nativeUnsignedTx != nil { + t.Fatal("did not expect native transaction substitution in observational mode") + } + + if len(logger.warningMessages) != 1 { + t.Fatalf( + "unexpected warning message count\nexpected: [%v]\nactual: [%v]", + 1, + len(logger.warningMessages), + ) + } + + if !strings.Contains(logger.warningMessages[0], "diverges") { + t.Fatalf("unexpected warning message: [%v]", logger.warningMessages[0]) + } + + if !strings.Contains(logger.warningMessages[0], "version mismatch") { + t.Fatalf("missing divergence detail in warning: [%v]", logger.warningMessages[0]) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_SubstitutionModeRejectsDivergence( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 999, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + }, + true, + ) + if err == nil { + t.Fatal("expected substitution-mode divergence error") + } + + if !strings.Contains(err.Error(), "diverges") { + t.Fatalf("unexpected substitution-mode error: [%v]", err) + } + + if !strings.Contains(err.Error(), "output value mismatch") { + t.Fatalf("missing divergence detail in substitution error: [%v]", err) + } + + if nativeUnsignedTx != nil { + t.Fatal("did not expect native transaction on divergence") + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warnings in substitution mode: [%v]", logger.warningMessages) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_SubstitutionModeAcceptsMatchingIO( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + nativeTransaction, + true, + ) + if err != nil { + t.Fatalf("unexpected substitution-mode evaluation error: [%v]", err) + } + + if nativeUnsignedTx == nil { + t.Fatal("expected native transaction substitution candidate") + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warnings in substitution mode: [%v]", logger.warningMessages) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_SubstitutionModeRejectsStructuralDivergence( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + expectedTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + expectedTransaction, + true, + ) + if err == nil { + t.Fatal("expected substitution-mode structural divergence error") + } + + if !strings.Contains(err.Error(), "diverges") { + t.Fatalf("unexpected substitution-mode error: [%v]", err) + } + + if !strings.Contains(err.Error(), "version mismatch") { + t.Fatalf("missing divergence detail in substitution error: [%v]", err) + } + + if nativeUnsignedTx != nil { + t.Fatal("did not expect native transaction on divergence") + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warnings in substitution mode: [%v]", logger.warningMessages) + } +} + +func TestNativeBuildTaprootTxSigningSubstitutionEnabled(t *testing.T) { + testCases := []struct { + name string + envValue string + expected bool + }{ + {name: "unset", envValue: "", expected: false}, + {name: "true", envValue: "true", expected: true}, + {name: "TRUE", envValue: "TRUE", expected: true}, + {name: "one", envValue: "1", expected: true}, + {name: "yes", envValue: "yes", expected: true}, + {name: "on", envValue: "on", expected: true}, + {name: "false", envValue: "false", expected: false}, + {name: "zero", envValue: "0", expected: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(nativeBuildTaprootTxSigningSubstitutionEnvVar, tc.envValue) + + actual := nativeBuildTaprootTxSigningSubstitutionEnabled() + if actual != tc.expected { + t.Fatalf( + "unexpected flag state\nexpected: [%v]\nactual: [%v]", + tc.expected, + actual, + ) + } + }) + } +} + +// The native tbtc-signer BuildTaprootTx parity/substitution path is gated on the +// transaction being all-Taproot. The substitution LOGIC itself (observational +// logging, divergence rejection, matching-IO acceptance) is covered directly by +// the TestEvaluateNativeUnsignedTransactionForSigning_* tests; these two tests +// cover the signTransaction gate: skip for legacy, invoke for Taproot. +func TestWalletTransactionExecutor_SignTransaction_SkipsNativeBuildForLegacyTransaction( + t *testing.T, +) { + privateKey, unsignedTx, _, _ := buildTaprootTxSubstitutionFixture(t) + + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + originalSigningSubstitutionEnabledFn := nativeBuildTaprootTxSigningSubstitutionEnabledFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + nativeBuildTaprootTxSigningSubstitutionEnabledFn = originalSigningSubstitutionEnabledFn + }) + + nativeBuildCalled := false + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + nativeBuildCalled = true + return "", nil + } + // Even with substitution enabled, the native Taproot builder must not run for + // a legacy (non-Taproot) transaction. + nativeBuildTaprootTxSigningSubstitutionEnabledFn = func() bool { + return true + } + + wte := &walletTransactionExecutor{ + executingWallet: wallet{ + publicKey: &privateKey.PublicKey, + }, + signingExecutor: &deterministicECDSASigningExecutorForBuildTaprootTxSubstitution{ + privateKey: privateKey, + }, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + logger := &warningCaptureLogger{} + + tx, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err != nil { + t.Fatalf("unexpected signTransaction error: [%v]", err) + } + + if nativeBuildCalled { + t.Fatal( + "native BuildTaprootTx must not be invoked for a legacy (non-Taproot) transaction", + ) + } + if len(tx.Inputs[0].SignatureScript) == 0 { + t.Fatal("expected the legacy transaction to be signed via the Go path") + } + if containsLoggedMessage( + logger.infoMessages, + "substituted Go unsigned transaction with native tbtc-signer BuildTaprootTx output", + ) { + t.Fatal("must not substitute a native transaction for a legacy transaction") + } +} + +func TestWalletTransactionExecutor_SignTransaction_SubstitutesNativeBuildForTaprootTransaction( + t *testing.T, +) { + unsignedTx, privateKey := buildTaprootKeyPathUnsignedTxForTest(t) + + // The native builder returns a transaction structurally identical to the + // Go-built one, so substitution mode accepts it and substitutes -- this + // exercises the ReplaceUnsignedTransaction call and the substitution info + // log in the real signTransaction caller. Returning a non-empty hex also + // proves the gate invoked the native build for an all-Taproot transaction. + nativeUnsignedTxHex := hex.EncodeToString( + unsignedTx.UnsignedTransaction().Serialize(bitcoin.Standard), + ) + + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + originalSigningSubstitutionEnabledFn := nativeBuildTaprootTxSigningSubstitutionEnabledFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + nativeBuildTaprootTxSigningSubstitutionEnabledFn = originalSigningSubstitutionEnabledFn + }) + + nativeBuildCalled := false + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + nativeBuildCalled = true + return nativeUnsignedTxHex, nil + } + nativeBuildTaprootTxSigningSubstitutionEnabledFn = func() bool { + return true + } + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: &deterministicSchnorrSigningExecutorForTaproot{ + privateKey: privateKey, + }, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + logger := &warningCaptureLogger{} + tx, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err != nil { + t.Fatalf("unexpected signTransaction error: [%v]", err) + } + + if !nativeBuildCalled { + t.Fatal( + "native BuildTaprootTx must be invoked for an all-Taproot transaction", + ) + } + if !containsLoggedMessage( + logger.infoMessages, + "substituted Go unsigned transaction with native tbtc-signer BuildTaprootTx output", + ) { + t.Fatalf("expected the substitution info log, got: [%v]", logger.infoMessages) + } + if len(tx.Inputs) != 1 || len(tx.Inputs[0].Witness) == 0 { + t.Fatal("expected the substituted transaction to be signed with a taproot witness") + } +} + +func TestWalletTransactionExecutor_SignTransaction_AppliesTaprootKeyPathSignatures( + t *testing.T, +) { + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + }) + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", nil + } + + privateKeyBytes := mustDecodeHex( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ) + privateKey, publicKey := btcec2.PrivKeyFromBytes(privateKeyBytes) + + var taprootOutputKey [32]byte + copy(taprootOutputKey[:], schnorr.SerializePubKey(publicKey)) + + inputScript, err := bitcoin.PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatalf("cannot create taproot input script: [%v]", err) + } + + var outputPublicKeyHash [20]byte + copy( + outputPublicKeyHash[:], + mustDecodeHex(t, "0202020202020202020202020202020202020202"), + ) + outputScript, err := bitcoin.PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatalf("cannot create output script: [%v]", err) + } + + localBitcoinChain := newLocalBitcoinChain() + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{ + 0x10, 0x11, 0x12, 0x13, + 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, + 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x22, 0x23, + 0x24, 0x25, 0x26, 0x27, + 0x28, 0x29, 0x2a, 0x2b, + 0x2c, 0x2d, 0x2e, 0x2f, + }, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + if err := localBitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatalf("cannot broadcast funding transaction: [%v]", err) + } + + unsignedTx := bitcoin.NewTransactionBuilder(localBitcoinChain) + if err := unsignedTx.AddTaprootKeyPathInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + ); err != nil { + t.Fatalf("cannot add taproot input: [%v]", err) + } + unsignedTx.AddOutput(&bitcoin.TransactionOutput{ + Value: 90000, + PublicKeyScript: outputScript, + }) + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: &deterministicSchnorrSigningExecutorForTaproot{ + privateKey: privateKey, + }, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 0, 0) + if err != nil { + t.Fatalf("unexpected signTransaction error: [%v]", err) + } + + expectedSignature := mustDecodeHex( + t, + "5e847a0c22486f3b89ff80edd5afaf4be550aa411a0a7e28cff19d2b5924d77102bbf9a0a51100f4fdfc8435d0e8ff0f61dfdeccd464b78c553b1b4414ac0877", + ) + + if len(tx.Inputs) != 1 { + t.Fatalf("unexpected input count: [%d]", len(tx.Inputs)) + } + if len(tx.Inputs[0].Witness) != 1 { + t.Fatalf("unexpected taproot witness: [%x]", tx.Inputs[0].Witness) + } + if !bytes.Equal(expectedSignature, tx.Inputs[0].Witness[0]) { + t.Fatalf( + "unexpected taproot witness signature\nexpected: [%x]\nactual: [%x]", + expectedSignature, + tx.Inputs[0].Witness[0], + ) + } + if len(tx.Inputs[0].SignatureScript) != 0 { + t.Fatalf( + "unexpected signature script for taproot input: [%x]", + tx.Inputs[0].SignatureScript, + ) + } +} + +func TestWalletTransactionExecutor_SignTransaction_AppliesTweakedTaprootKeyPathSignatures( + t *testing.T, +) { + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + }) + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", nil + } + + internalPrivateKeyBytes := mustDecodeHex( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ) + _, internalPublicKey := btcec2.PrivKeyFromBytes(internalPrivateKeyBytes) + + var internalKey [32]byte + copy(internalKey[:], schnorr.SerializePubKey(internalPublicKey)) + + refundLeaf := bitcoin.Script(mustDecodeHex( + t, + "76a9140102030405060708090a0b0c0d0e0f101112131488ac", + )) + merkleRoot, err := bitcoin.TaprootLeafHash(refundLeaf) + if err != nil { + t.Fatalf("cannot compute taproot leaf hash: [%v]", err) + } + + taprootOutputKey, err := bitcoin.TaprootOutputKey(internalKey, &merkleRoot) + if err != nil { + t.Fatalf("cannot derive taproot output key: [%v]", err) + } + + inputScript, err := bitcoin.PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatalf("cannot create taproot input script: [%v]", err) + } + + var outputPublicKeyHash [20]byte + copy( + outputPublicKeyHash[:], + mustDecodeHex(t, "0202020202020202020202020202020202020202"), + ) + outputScript, err := bitcoin.PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatalf("cannot create output script: [%v]", err) + } + + localBitcoinChain := newLocalBitcoinChain() + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x10}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + if err := localBitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatalf("cannot broadcast funding transaction: [%v]", err) + } + + unsignedTx := bitcoin.NewTransactionBuilder(localBitcoinChain) + if err := unsignedTx.AddTaprootKeyPathInputWithMerkleRoot( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + internalKey, + merkleRoot, + ); err != nil { + t.Fatalf("cannot add tweaked taproot input: [%v]", err) + } + unsignedTx.AddOutput(&bitcoin.TransactionOutput{ + Value: 90000, + PublicKeyScript: outputScript, + }) + + tweakedPrivateKeyBytes := mustDecodeHex( + t, + "6ba56a44ff544e35d38fd126659aa68b2c4677a7ebbf7464ad2e9d86c18e1149", + ) + tweakedPrivateKey, _ := btcec2.PrivKeyFromBytes(tweakedPrivateKeyBytes) + + signingExecutor := &taprootMerkleRootRecordingSchnorrSigningExecutor{ + privateKey: tweakedPrivateKey, + } + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: signingExecutor, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 0, 0) + if err != nil { + t.Fatalf("unexpected signTransaction error: [%v]", err) + } + + if signingExecutor.signBatchCalled { + t.Fatal("ordinary signBatch must not be called for tweaked taproot input") + } + + if len(signingExecutor.taprootMerkleRoots) != 1 { + t.Fatalf( + "unexpected taproot merkle root count\nexpected: [%d]\nactual: [%d]", + 1, + len(signingExecutor.taprootMerkleRoots), + ) + } + if signingExecutor.taprootMerkleRoots[0] == nil { + t.Fatal("expected taproot merkle root") + } + if !bytes.Equal(signingExecutor.taprootMerkleRoots[0][:], merkleRoot[:]) { + t.Fatalf( + "unexpected taproot merkle root\nexpected: [%x]\nactual: [%x]", + merkleRoot, + *signingExecutor.taprootMerkleRoots[0], + ) + } + + if len(tx.Inputs) != 1 { + t.Fatalf("unexpected input count: [%d]", len(tx.Inputs)) + } + if len(tx.Inputs[0].Witness) != 1 { + t.Fatalf("unexpected taproot witness: [%x]", tx.Inputs[0].Witness) + } + if len(tx.Inputs[0].SignatureScript) != 0 { + t.Fatalf( + "unexpected signature script for taproot input: [%x]", + tx.Inputs[0].SignatureScript, + ) + } +} + +func TestWalletTransactionExecutor_SignTransaction_RejectsMixedTaprootAndLegacyInputsBeforeSigning( + t *testing.T, +) { + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + }) + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", nil + } + + var taprootOutputKey [32]byte + copy( + taprootOutputKey[:], + mustDecodeHex( + t, + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ), + ) + taprootInputScript, err := bitcoin.PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatalf("cannot create taproot input script: [%v]", err) + } + + var witnessPublicKeyHash [20]byte + copy( + witnessPublicKeyHash[:], + mustDecodeHex(t, "0202020202020202020202020202020202020202"), + ) + witnessInputScript, err := bitcoin.PayToWitnessPublicKeyHash( + witnessPublicKeyHash, + ) + if err != nil { + t.Fatalf("cannot create witness input script: [%v]", err) + } + + localBitcoinChain := newLocalBitcoinChain() + taprootFundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: taprootInputScript, + }, + }, + Locktime: 0, + } + if err := localBitcoinChain.BroadcastTransaction( + taprootFundingTransaction, + ); err != nil { + t.Fatalf("cannot broadcast taproot funding transaction: [%v]", err) + } + + legacyFundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x02}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 50000, + PublicKeyScript: witnessInputScript, + }, + }, + Locktime: 0, + } + if err := localBitcoinChain.BroadcastTransaction( + legacyFundingTransaction, + ); err != nil { + t.Fatalf("cannot broadcast legacy funding transaction: [%v]", err) + } + + unsignedTx := bitcoin.NewTransactionBuilder(localBitcoinChain) + if err := unsignedTx.AddTaprootKeyPathInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: taprootFundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + ); err != nil { + t.Fatalf("cannot add taproot input: [%v]", err) + } + if err := unsignedTx.AddPublicKeyHashInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: legacyFundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 50000, + }, + ); err != nil { + t.Fatalf("cannot add legacy input: [%v]", err) + } + unsignedTx.AddOutput(&bitcoin.TransactionOutput{ + Value: 140000, + PublicKeyScript: witnessInputScript, + }) + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: &unexpectedSigningExecutorForBuildTaprootTxError{}, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 0, 0) + if err == nil { + t.Fatal("expected mixed taproot and legacy signing error") + } + if tx != nil { + t.Fatal("expected no signed transaction") + } + if !strings.Contains(err.Error(), "mixed taproot and legacy inputs") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestWalletTransactionExecutor_SignTransaction_RejectsSchnorrForLegacyInputsBeforeSigning( + t *testing.T, +) { + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + }) + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", nil + } + + var publicKeyHash [20]byte + copy( + publicKeyHash[:], + mustDecodeHex(t, "0202020202020202020202020202020202020202"), + ) + inputScript, err := bitcoin.PayToWitnessPublicKeyHash(publicKeyHash) + if err != nil { + t.Fatalf("cannot create witness input script: [%v]", err) + } + + localBitcoinChain := newLocalBitcoinChain() + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x03}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + if err := localBitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatalf("cannot broadcast funding transaction: [%v]", err) + } + + unsignedTx := bitcoin.NewTransactionBuilder(localBitcoinChain) + if err := unsignedTx.AddPublicKeyHashInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + ); err != nil { + t.Fatalf("cannot add legacy input: [%v]", err) + } + unsignedTx.AddOutput(&bitcoin.TransactionOutput{ + Value: 90000, + PublicKeyScript: inputScript, + }) + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: &deterministicSchnorrSigningExecutorForTaproot{}, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 0, 0) + if err == nil { + t.Fatal("expected schnorr non-taproot signing error") + } + if tx != nil { + t.Fatal("expected no signed transaction") + } + if !strings.Contains(err.Error(), "non-taproot transaction inputs") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +// buildTaprootKeyPathUnsignedTxForTest builds an all-Taproot-key-path unsigned +// transaction (and returns the key controlling its single input) for exercising +// the native BuildTaprootTx gate / signing path. +func buildTaprootKeyPathUnsignedTxForTest( + t *testing.T, +) (*bitcoin.TransactionBuilder, *btcec2.PrivateKey) { + t.Helper() + + privateKeyBytes := mustDecodeHex( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ) + privateKey, publicKey := btcec2.PrivKeyFromBytes(privateKeyBytes) + + var taprootOutputKey [32]byte + copy(taprootOutputKey[:], schnorr.SerializePubKey(publicKey)) + + inputScript, err := bitcoin.PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatalf("cannot create taproot input script: [%v]", err) + } + + var outputPublicKeyHash [20]byte + copy( + outputPublicKeyHash[:], + mustDecodeHex(t, "0202020202020202020202020202020202020202"), + ) + outputScript, err := bitcoin.PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatalf("cannot create output script: [%v]", err) + } + + localBitcoinChain := newLocalBitcoinChain() + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{ + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, + 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, + }, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + if err := localBitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatalf("cannot broadcast funding transaction: [%v]", err) + } + + unsignedTx := bitcoin.NewTransactionBuilder(localBitcoinChain) + if err := unsignedTx.AddTaprootKeyPathInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + ); err != nil { + t.Fatalf("cannot add taproot input: [%v]", err) + } + unsignedTx.AddOutput(&bitcoin.TransactionOutput{ + Value: 90000, + PublicKeyScript: outputScript, + }) + + return unsignedTx, privateKey +} + +func buildTaprootTxSubstitutionFixture( + t *testing.T, +) ( + *ecdsa.PrivateKey, + *bitcoin.TransactionBuilder, + string, + *bitcoin.Transaction, +) { + privateKey := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: tecdsa.Curve, + }, + D: big.NewInt(111), + } + privateKey.PublicKey.X, privateKey.PublicKey.Y = tecdsa.Curve.ScalarBaseMult( + privateKey.D.Bytes(), + ) + + pubKeyHash := [20]byte{} + for i := range pubKeyHash { + pubKeyHash[i] = byte(i + 1) + } + + lockingScript, err := bitcoin.PayToPublicKeyHash(pubKeyHash) + if err != nil { + t.Fatalf("cannot create locking script: [%v]", err) + } + + localBitcoinChain := newLocalBitcoinChain() + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{}, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 10000, + PublicKeyScript: lockingScript, + }, + }, + Locktime: 0, + } + + if err := localBitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatalf("cannot broadcast funding transaction: [%v]", err) + } + + unsignedTx := bitcoin.NewTransactionBuilder(localBitcoinChain) + if err := unsignedTx.AddPublicKeyHashInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 10000, + }, + ); err != nil { + t.Fatalf("cannot add unsigned input: [%v]", err) + } + + replacementOutputScript := mustDecodeHex(t, "0014deadbeef") + unsignedTx.AddOutput( + &bitcoin.TransactionOutput{ + Value: 9000, + PublicKeyScript: replacementOutputScript, + }, + ) + + nativeUnsignedTx := &bitcoin.Transaction{ + Version: 1, + Locktime: 0, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 9000, + PublicKeyScript: replacementOutputScript, + }, + }, + } + + return privateKey, + unsignedTx, + hex.EncodeToString(nativeUnsignedTx.Serialize(bitcoin.Standard)), + nativeUnsignedTx +} + +func mustDecodeHex(t *testing.T, value string) []byte { + result, err := hex.DecodeString(value) + if err != nil { + t.Fatalf("cannot decode hex: [%v]", err) + } + + return result +} + +type warningCaptureLogger struct { + warningMessages []string + infoMessages []string +} + +func (wcl *warningCaptureLogger) Debug(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Debugf(format string, args ...interface{}) {} + +func (wcl *warningCaptureLogger) Error(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Errorf(format string, args ...interface{}) {} + +func (wcl *warningCaptureLogger) Fatal(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Fatalf(format string, args ...interface{}) {} + +func (wcl *warningCaptureLogger) Info(args ...interface{}) { + wcl.infoMessages = append(wcl.infoMessages, fmt.Sprint(args...)) +} + +func (wcl *warningCaptureLogger) Infof(format string, args ...interface{}) { + wcl.infoMessages = append(wcl.infoMessages, fmt.Sprintf(format, args...)) +} + +func (wcl *warningCaptureLogger) Panic(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Panicf(format string, args ...interface{}) {} + +func (wcl *warningCaptureLogger) Warn(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Warnf(format string, args ...interface{}) { + wcl.warningMessages = append( + wcl.warningMessages, + fmt.Sprintf(format, args...), + ) +} + +func containsLoggedMessage(messages []string, substring string) bool { + for _, message := range messages { + if strings.Contains(message, substring) { + return true + } + } + + return false +} + +type deterministicECDSASigningExecutorForBuildTaprootTxSubstitution struct { + privateKey *ecdsa.PrivateKey +} + +func (desefbts *deterministicECDSASigningExecutorForBuildTaprootTxSubstitution) signBatch( + ctx context.Context, + messages []*big.Int, + startBlock uint64, +) ([]*frost.Signature, error) { + signatures := make([]*frost.Signature, 0, len(messages)) + + for _, message := range messages { + r, s, err := ecdsa.Sign( + rand.Reader, + desefbts.privateKey, + message.Bytes(), + ) + if err != nil { + return nil, err + } + + signature := &frost.Signature{} + rBytes := r.Bytes() + copy(signature.R[len(signature.R)-len(rBytes):], rBytes) + sBytes := s.Bytes() + copy(signature.S[len(signature.S)-len(sBytes):], sBytes) + + signatures = append(signatures, signature) + } + + return signatures, nil +} + +type deterministicSchnorrSigningExecutorForTaproot struct { + privateKey *btcec2.PrivateKey +} + +func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatch( + ctx context.Context, + messages []*big.Int, + startBlock uint64, +) ([]*frost.Signature, error) { + signatures := make([]*frost.Signature, 0, len(messages)) + + for _, message := range messages { + signature, err := schnorr.Sign( + dsseft.privateKey, + message.FillBytes(make([]byte, 32)), + ) + if err != nil { + return nil, err + } + + serialized := signature.Serialize() + frostSignature := &frost.Signature{} + copy(frostSignature.R[:], serialized[:32]) + copy(frostSignature.S[:], serialized[32:]) + + signatures = append(signatures, frostSignature) + } + + return signatures, nil +} + +func (dsseft *deterministicSchnorrSigningExecutorForTaproot) usesSchnorrSignatures() bool { + return true +} + +type taprootMerkleRootRecordingSchnorrSigningExecutor struct { + privateKey *btcec2.PrivateKey + signBatchCalled bool + taprootMerkleRoots []*[32]byte +} + +func (tmrrsse *taprootMerkleRootRecordingSchnorrSigningExecutor) signBatch( + ctx context.Context, + messages []*big.Int, + startBlock uint64, +) ([]*frost.Signature, error) { + tmrrsse.signBatchCalled = true + return nil, errors.New("unexpected signBatch invocation") +} + +func (tmrrsse *taprootMerkleRootRecordingSchnorrSigningExecutor) signBatchWithTaprootMerkleRoots( + ctx context.Context, + messages []*big.Int, + taprootMerkleRoots []*[32]byte, + startBlock uint64, +) ([]*frost.Signature, error) { + tmrrsse.taprootMerkleRoots = make([]*[32]byte, len(taprootMerkleRoots)) + for i, taprootMerkleRoot := range taprootMerkleRoots { + if taprootMerkleRoot == nil { + continue + } + + tmrrsse.taprootMerkleRoots[i] = new([32]byte) + copy(tmrrsse.taprootMerkleRoots[i][:], taprootMerkleRoot[:]) + } + + signatures := make([]*frost.Signature, 0, len(messages)) + + for _, message := range messages { + signature, err := schnorr.Sign( + tmrrsse.privateKey, + message.FillBytes(make([]byte, 32)), + ) + if err != nil { + return nil, err + } + + serialized := signature.Serialize() + frostSignature := &frost.Signature{} + copy(frostSignature.R[:], serialized[:32]) + copy(frostSignature.S[:], serialized[32:]) + + signatures = append(signatures, frostSignature) + } + + return signatures, nil +} + +func (tmrrsse *taprootMerkleRootRecordingSchnorrSigningExecutor) usesSchnorrSignatures() bool { + return true +} + +type unexpectedSigningExecutorForBuildTaprootTxError struct{} + +func (usefbte *unexpectedSigningExecutorForBuildTaprootTxError) signBatch( + ctx context.Context, + messages []*big.Int, + startBlock uint64, +) ([]*frost.Signature, error) { + return nil, errors.New("unexpected signBatch invocation") +} diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 9ef4e41576..f3f7f091f6 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -19,6 +19,7 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" ) @@ -494,13 +495,13 @@ func TestWalletTransactionExecutor_SignTransaction_Success(t *testing.T) { } privKey := &ecdsa.PrivateKey{PublicKey: *walletObj.publicKey, D: privKeyScalar} - sigs := make([]*tecdsa.Signature, len(sigHashes)) + sigs := make([]*frost.Signature, len(sigHashes)) for i, h := range sigHashes { r, s, err := ecdsa.Sign(rand.Reader, privKey, h.Bytes()) if err != nil { t.Fatal(err) } - sigs[i] = &tecdsa.Signature{R: r, S: s} + sigs[i] = mustFrostSignatureFromTECDSA(&tecdsa.Signature{R: r, S: s}) } const startBlock = uint64(0) @@ -615,12 +616,12 @@ func generateWallet(privateKey *big.Int) wallet { type mockWalletSigningExecutor struct { signaturesMutex sync.Mutex - signatures map[[32]byte][]*tecdsa.Signature + signatures map[[32]byte][]*frost.Signature } func newMockWalletSigningExecutor() *mockWalletSigningExecutor { return &mockWalletSigningExecutor{ - signatures: make(map[[32]byte][]*tecdsa.Signature), + signatures: make(map[[32]byte][]*frost.Signature), } } @@ -628,7 +629,7 @@ func (mwse *mockWalletSigningExecutor) signBatch( ctx context.Context, messages []*big.Int, startBlock uint64, -) ([]*tecdsa.Signature, error) { +) ([]*frost.Signature, error) { mwse.signaturesMutex.Lock() defer mwse.signaturesMutex.Unlock() @@ -645,7 +646,7 @@ func (mwse *mockWalletSigningExecutor) signBatch( func (mwse *mockWalletSigningExecutor) setSignatures( messages []*big.Int, startBlock uint64, - signatures []*tecdsa.Signature, + signatures []*frost.Signature, ) { mwse.signaturesMutex.Lock() defer mwse.signaturesMutex.Unlock() diff --git a/pkg/tbtc/wallet_utxo_script.go b/pkg/tbtc/wallet_utxo_script.go new file mode 100644 index 0000000000..2cb8cf3bef --- /dev/null +++ b/pkg/tbtc/wallet_utxo_script.go @@ -0,0 +1,72 @@ +package tbtc + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +func walletMainUtxoScriptType( + bitcoinChain bitcoin.Chain, + walletMainUtxo *bitcoin.UnspentTransactionOutput, +) (bitcoin.ScriptType, error) { + return walletUtxoScriptType(bitcoinChain, walletMainUtxo) +} + +func walletUtxoScriptType( + bitcoinChain bitcoin.Chain, + walletMainUtxo *bitcoin.UnspentTransactionOutput, +) (bitcoin.ScriptType, error) { + if walletMainUtxo == nil { + return bitcoin.NonStandardScript, fmt.Errorf("wallet main UTXO is required") + } + + if walletMainUtxo.Outpoint == nil { + return bitcoin.NonStandardScript, fmt.Errorf( + "wallet main UTXO outpoint is required", + ) + } + + transaction, err := bitcoinChain.GetTransaction( + walletMainUtxo.Outpoint.TransactionHash, + ) + if err != nil { + return bitcoin.NonStandardScript, fmt.Errorf( + "cannot get transaction with hash [%s]: [%v]", + walletMainUtxo.Outpoint.TransactionHash.Hex(bitcoin.InternalByteOrder), + err, + ) + } + + outputIndex := walletMainUtxo.Outpoint.OutputIndex + if outputIndex >= uint32(len(transaction.Outputs)) { + return bitcoin.NonStandardScript, fmt.Errorf( + "output index [%d] out of range for transaction [%s] "+ + "with [%d] outputs", + outputIndex, + walletMainUtxo.Outpoint.TransactionHash.Hex(bitcoin.InternalByteOrder), + len(transaction.Outputs), + ) + } + + return bitcoin.GetScriptType( + transaction.Outputs[outputIndex].PublicKeyScript, + ), nil +} + +func addWalletUtxoInput( + builder *bitcoin.TransactionBuilder, + bitcoinChain bitcoin.Chain, + utxo *bitcoin.UnspentTransactionOutput, +) error { + scriptType, err := walletUtxoScriptType(bitcoinChain, utxo) + if err != nil { + return err + } + + if scriptType == bitcoin.P2TRScript { + return builder.AddTaprootKeyPathInput(utxo) + } + + return builder.AddPublicKeyHashInput(utxo) +} diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index 01e519c462..1cdbd8e6d8 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -104,6 +104,19 @@ type Chain interface { }, ) error + // ValidateTaprootDepositSweepProposal validates the given Taproot deposit + // sweep proposal against the chain. It requires some additional data about + // the deposits that must be fetched externally. Returns an error if the + // proposal is not valid or nil otherwise. + ValidateTaprootDepositSweepProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.DepositSweepProposal, + depositsExtraInfo []struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, + ) error + // ValidateRedemptionProposal validates the given redemption proposal // against the chain. Returns an error if the proposal is not valid or // nil otherwise. @@ -120,9 +133,18 @@ type Chain interface { AverageBlockTime() time.Duration + // CurrentBlockTimestamp gets the timestamp of the current anchoring chain + // block. Proposal eligibility checks should use this timestamp instead of + // the local process clock because Bridge validators use block.timestamp. + CurrentBlockTimestamp() (time.Time, error) + // GetOperatorID returns the operator ID for the given operator address. GetOperatorID(operatorAddress chain.Address) (chain.OperatorID, error) + // GetFrostOperatorID returns the FROST sortition pool operator ID for the + // given operator address. + GetFrostOperatorID(operatorAddress chain.Address) (chain.OperatorID, error) + // ValidateHeartbeatProposal validates the given heartbeat proposal // against the chain. Returns an error if the proposal is not valid or // nil otherwise. diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 52f6ef4137..567a99e6d0 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -75,6 +75,7 @@ type LocalChain struct { depositRequests map[[32]byte]*tbtc.DepositChainRequest pastDepositRevealedEvents map[[32]byte][]*tbtc.DepositRevealedEvent + pastTaprootDepositRevealedEvents map[[32]byte][]*tbtc.TaprootDepositRevealedEvent pastNewWalletRegisteredEvents map[[32]byte][]*tbtc.NewWalletRegisteredEvent depositParameters depositParameters depositSweepProposalValidations map[[32]byte]bool @@ -96,14 +97,17 @@ type LocalChain struct { movedFundsSweepRequests map[[32]byte]*tbtc.MovedFundsSweepRequest movedFundsSweepProposalValidations map[[32]byte]bool operatorIDs map[chain.Address]uint32 + frostOperatorIDs map[chain.Address]uint32 redemptionDelays map[[32]byte]time.Duration depositMinAge uint32 + currentBlockTimestamp time.Time } func NewLocalChain() *LocalChain { return &LocalChain{ depositRequests: make(map[[32]byte]*tbtc.DepositChainRequest), pastDepositRevealedEvents: make(map[[32]byte][]*tbtc.DepositRevealedEvent), + pastTaprootDepositRevealedEvents: make(map[[32]byte][]*tbtc.TaprootDepositRevealedEvent), pastNewWalletRegisteredEvents: make(map[[32]byte][]*tbtc.NewWalletRegisteredEvent), depositSweepProposalValidations: make(map[[32]byte]bool), pastRedemptionRequestedEvents: make(map[[32]byte][]*tbtc.RedemptionRequestedEvent), @@ -118,7 +122,9 @@ func NewLocalChain() *LocalChain { movedFundsSweepRequests: make(map[[32]byte]*tbtc.MovedFundsSweepRequest), movedFundsSweepProposalValidations: make(map[[32]byte]bool), operatorIDs: make(map[chain.Address]uint32), + frostOperatorIDs: make(map[chain.Address]uint32), redemptionDelays: make(map[[32]byte]time.Duration), + currentBlockTimestamp: time.Now(), } } @@ -161,6 +167,45 @@ func (lc *LocalChain) AddPastDepositRevealedEvent( return nil } +func (lc *LocalChain) PastTaprootDepositRevealedEvents( + filter *tbtc.DepositRevealedEventFilter, +) ([]*tbtc.TaprootDepositRevealedEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + eventsKey, err := buildPastDepositRevealedEventsKey(filter) + if err != nil { + return nil, err + } + + events, ok := lc.pastTaprootDepositRevealedEvents[eventsKey] + if !ok { + return []*tbtc.TaprootDepositRevealedEvent{}, nil + } + + return events, nil +} + +func (lc *LocalChain) AddPastTaprootDepositRevealedEvent( + filter *tbtc.DepositRevealedEventFilter, + event *tbtc.TaprootDepositRevealedEvent, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + eventsKey, err := buildPastDepositRevealedEventsKey(filter) + if err != nil { + return err + } + + lc.pastTaprootDepositRevealedEvents[eventsKey] = append( + lc.pastTaprootDepositRevealedEvents[eventsKey], + event, + ) + + return nil +} + func buildPastDepositRevealedEventsKey( filter *tbtc.DepositRevealedEventFilter, ) ([32]byte, error) { @@ -615,6 +660,21 @@ func (lc *LocalChain) ValidateDepositSweepProposal( return nil } +func (lc *LocalChain) ValidateTaprootDepositSweepProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.DepositSweepProposal, + depositsExtraInfo []struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + return lc.ValidateDepositSweepProposal( + walletPublicKeyHash, + proposal, + depositsExtraInfo, + ) +} + func (lc *LocalChain) SetDepositSweepProposalValidationResult( walletPublicKeyHash [20]byte, proposal *tbtc.DepositSweepProposal, @@ -985,6 +1045,20 @@ func (lc *LocalChain) AverageBlockTime() time.Duration { return lc.averageBlockTime } +func (lc *LocalChain) CurrentBlockTimestamp() (time.Time, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return lc.currentBlockTimestamp, nil +} + +func (lc *LocalChain) SetCurrentBlockTimestamp(currentBlockTimestamp time.Time) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.currentBlockTimestamp = currentBlockTimestamp +} + func (lc *LocalChain) SetOperatorID( operatorAddress chain.Address, operatorID chain.OperatorID, @@ -1014,6 +1088,35 @@ func (lc *LocalChain) GetOperatorID( return operatorID, nil } +func (lc *LocalChain) SetFrostOperatorID( + operatorAddress chain.Address, + operatorID chain.OperatorID, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + _, ok := lc.frostOperatorIDs[operatorAddress] + if !ok { + lc.frostOperatorIDs[operatorAddress] = operatorID + } + + return nil +} + +func (lc *LocalChain) GetFrostOperatorID( + operatorAddress chain.Address, +) (chain.OperatorID, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + operatorID, ok := lc.frostOperatorIDs[operatorAddress] + if !ok { + return 0, fmt.Errorf("FROST operator not found") + } + + return operatorID, nil +} + func (lc *LocalChain) SetAverageBlockTime(averageBlockTime time.Duration) { lc.mutex.Lock() defer lc.mutex.Unlock() @@ -1047,6 +1150,25 @@ func (lc *LocalChain) GetWallet(walletPublicKeyHash [20]byte) ( return data, nil } +func (lc *LocalChain) WalletPublicKeyHashForWalletID( + walletID [32]byte, +) ([20]byte, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + for walletPublicKeyHash, walletData := range lc.walletChainData { + if walletData == nil { + continue + } + + if walletData.WalletID == walletID || walletData.EcdsaWalletID == walletID { + return walletPublicKeyHash, nil + } + } + + return [20]byte{}, fmt.Errorf("wallet public key hash for wallet ID not found") +} + func (lc *LocalChain) SetWallet( walletPublicKeyHash [20]byte, data *tbtc.WalletChainData, diff --git a/pkg/tbtcpg/deposit_sweep.go b/pkg/tbtcpg/deposit_sweep.go index 491e4411fa..c3cb873be2 100644 --- a/pkg/tbtcpg/deposit_sweep.go +++ b/pkg/tbtcpg/deposit_sweep.go @@ -114,6 +114,7 @@ type Deposit struct { WalletPublicKeyHash [20]byte DepositKey string IsSwept bool + IsTaproot bool AmountBtc float64 Confirmations uint Vault *chain.Address @@ -147,7 +148,7 @@ func FindDeposits( // deposit-revealed events are queried. func findDeposits( fnLogger log.StandardLogger, - chain Chain, + hostChain Chain, btcChain bitcoin.Chain, walletPublicKeyHash [20]byte, maxNumberOfDeposits int, @@ -157,7 +158,7 @@ func findDeposits( ) ([]*Deposit, error) { fnLogger.Infof("reading revealed deposits from chain") - depositMinAgeSeconds, err := chain.GetDepositMinAge() + depositMinAgeSeconds, err := hostChain.GetDepositMinAge() if err != nil { return nil, fmt.Errorf( "failed to get deposit minimum age: [%w]", @@ -173,7 +174,7 @@ func findDeposits( filter.WalletPublicKeyHash = [][20]byte{walletPublicKeyHash} } - depositRevealedEvents, err := chain.PastDepositRevealedEvents(filter) + depositRevealedEvents, err := hostChain.PastDepositRevealedEvents(filter) if err != nil { return []*Deposit{}, fmt.Errorf( "failed to get past deposit revealed events: [%w]", @@ -181,35 +182,122 @@ func findDeposits( ) } - fnLogger.Infof("found [%d] DepositRevealed events", len(depositRevealedEvents)) + taprootDepositRevealedEvents, err := hostChain.PastTaprootDepositRevealedEvents(filter) + if err != nil { + return []*Deposit{}, fmt.Errorf( + "failed to get past Taproot deposit revealed events: [%w]", + err, + ) + } + + fnLogger.Infof( + "found [%d] DepositRevealed events and [%d] TaprootDepositRevealed events", + len(depositRevealedEvents), + len(taprootDepositRevealedEvents), + ) + + type revealedDepositEvent struct { + FundingTxHash bitcoin.Hash + FundingOutputIndex uint32 + WalletPublicKeyHash [20]byte + Amount uint64 + Vault *chain.Address + BlockNumber uint64 + IsTaproot bool + } + + revealedDepositEvents := make( + []*revealedDepositEvent, + 0, + len(depositRevealedEvents)+len(taprootDepositRevealedEvents), + ) + + type revealedDepositKey struct { + FundingTxHash bitcoin.Hash + FundingOutputIndex uint32 + } + + revealedDepositEventsIndex := make(map[revealedDepositKey]int) + appendRevealedDepositEvent := func(event *revealedDepositEvent) { + key := revealedDepositKey{ + FundingTxHash: event.FundingTxHash, + FundingOutputIndex: event.FundingOutputIndex, + } + + if existingIndex, ok := revealedDepositEventsIndex[key]; ok { + // A Taproot reveal may also emit a compatibility DepositRevealed + // event. Keep only the Taproot-specific representation so the + // same deposit cannot appear in both legacy and Taproot sweep groups. + if event.IsTaproot { + revealedDepositEvents[existingIndex] = event + } + + return + } + + revealedDepositEventsIndex[key] = len(revealedDepositEvents) + revealedDepositEvents = append(revealedDepositEvents, event) + } + + for _, event := range depositRevealedEvents { + appendRevealedDepositEvent( + &revealedDepositEvent{ + FundingTxHash: event.FundingTxHash, + FundingOutputIndex: event.FundingOutputIndex, + WalletPublicKeyHash: event.WalletPublicKeyHash, + Amount: event.Amount, + Vault: event.Vault, + BlockNumber: event.BlockNumber, + }, + ) + } + + for _, event := range taprootDepositRevealedEvents { + appendRevealedDepositEvent( + &revealedDepositEvent{ + FundingTxHash: event.FundingTxHash, + FundingOutputIndex: event.FundingOutputIndex, + WalletPublicKeyHash: event.WalletPublicKeyHash, + Amount: event.Amount, + Vault: event.Vault, + BlockNumber: event.BlockNumber, + IsTaproot: true, + }, + ) + } // Take the oldest first - sort.SliceStable(depositRevealedEvents, func(i, j int) bool { - return depositRevealedEvents[i].BlockNumber < depositRevealedEvents[j].BlockNumber + sort.SliceStable(revealedDepositEvents, func(i, j int) bool { + return revealedDepositEvents[i].BlockNumber < revealedDepositEvents[j].BlockNumber }) fnLogger.Infof("getting deposits details") - resultSliceCapacity := len(depositRevealedEvents) + resultSliceCapacity := len(revealedDepositEvents) if maxNumberOfDeposits > 0 { resultSliceCapacity = maxNumberOfDeposits } - // Capture time now for computations. - timeNow := time.Now() + timeNow, err := hostChain.CurrentBlockTimestamp() + if err != nil { + return nil, fmt.Errorf( + "failed to get current block timestamp: [%w]", + err, + ) + } result := make([]*Deposit, 0, resultSliceCapacity) - for _, event := range depositRevealedEvents { + for _, event := range revealedDepositEvents { if len(result) == cap(result) { break } - depositKey := chain.BuildDepositKey(event.FundingTxHash, event.FundingOutputIndex) + depositKey := hostChain.BuildDepositKey(event.FundingTxHash, event.FundingOutputIndex) depositKeyStr := depositKey.Text(16) fnLogger.Debugf("getting details of deposit [%s]", depositKeyStr) - depositRequest, found, err := chain.GetDepositRequest( + depositRequest, found, err := hostChain.GetDepositRequest( event.FundingTxHash, event.FundingOutputIndex, ) @@ -268,6 +356,7 @@ func findDeposits( WalletPublicKeyHash: event.WalletPublicKeyHash, DepositKey: hexutils.Encode(depositKey.Bytes()), IsSwept: isSwept, + IsTaproot: event.IsTaproot, AmountBtc: convertSatToBtc(float64(depositRequest.Amount)), Confirmations: confirmations, Vault: depositRequest.Vault, @@ -351,6 +440,10 @@ func (dst *DepositSweepTask) FindDepositsToSweep( for _, deposit := range unsweptDeposits { var key string var label string + scriptType := "legacy" + if deposit.IsTaproot { + scriptType = "taproot" + } if deposit.Vault == nil { key = "" @@ -359,6 +452,8 @@ func (dst *DepositSweepTask) FindDepositsToSweep( key = strings.ToLower(string(*deposit.Vault)) label = string(*deposit.Vault) } + key = fmt.Sprintf("%s:%s", scriptType, key) + label = fmt.Sprintf("%s, %s", label, scriptType) g, exists := groups[key] if !exists { @@ -413,13 +508,15 @@ func (dst *DepositSweepTask) FindDepositsToSweep( // different vault, making it eligible for a future sweep. // The Warn-level log below flags these deposits for operator // awareness and manual follow-up. - if nilGroup, ok := groups[""]; ok { - for _, deposit := range nilGroup.deposits { - taskLogger.Warnf( - "vault=0x0 deposit [%s] with wallet PKH [0x%x] requires manual follow-up", - deposit.DepositKey, - deposit.WalletPublicKeyHash, - ) + for _, nilGroupKey := range []string{"legacy:", "taproot:"} { + if nilGroup, ok := groups[nilGroupKey]; ok { + for _, deposit := range nilGroup.deposits { + taskLogger.Warnf( + "vault=0x0 deposit [%s] with wallet PKH [0x%x] requires manual follow-up", + deposit.DepositKey, + deposit.WalletPublicKeyHash, + ) + } } } } diff --git a/pkg/tbtcpg/deposit_sweep_test.go b/pkg/tbtcpg/deposit_sweep_test.go index dbfd9e24ea..9873f39bdb 100644 --- a/pkg/tbtcpg/deposit_sweep_test.go +++ b/pkg/tbtcpg/deposit_sweep_test.go @@ -188,6 +188,85 @@ func TestDepositSweepTask_FindDepositsToSweep_UnderflowGuard(t *testing.T) { } } +func TestDepositSweepTask_FindDepositsToSweep_UsesChainTimestamp(t *testing.T) { + currentBlock := uint64(100000) + chainNow := time.Now().Add(3 * time.Hour) + + walletPublicKeyHash := hexToByte20( + "7670343fc00ccc2d0cd65360e6ad400697ea0fed", + ) + + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + tbtcChain.SetBlockCounter(blockCounter) + + tbtcChain.SetCurrentBlockTimestamp(chainNow) + tbtcChain.SetDepositMinAge(3600) + + fundingTxHash := hashFromString( + "f7fc639dd598e70a423fd28d39ca2f2d01e93523b847f601b78ac5a2b6da51da", + ) + + tbtcChain.SetDepositRequest( + fundingTxHash, + uint32(1), + &tbtc.DepositChainRequest{ + // This timestamp is deliberately in the future compared to the + // host clock, but mature compared to the chain clock. + RevealedAt: chainNow.Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + }, + ) + + btcChain.SetTransaction(fundingTxHash, &bitcoin.Transaction{}) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + + err := tbtcChain.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: 0, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 90000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 1, + }, + ) + if err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewDepositSweepTask(tbtcChain, btcChain) + + deposits, err := task.FindDepositsToSweep( + &testutils.MockLogger{}, + walletPublicKeyHash, + 5, + ) + if err != nil { + t.Fatal(err) + } + + if len(deposits) != 1 { + t.Fatalf("expected 1 deposit, got %d", len(deposits)) + } + + if deposits[0].FundingTxHash != fundingTxHash { + t.Errorf("unexpected funding tx hash") + } + + if deposits[0].FundingOutputIndex != 1 { + t.Errorf("unexpected funding output index") + } +} + func TestDepositSweepTask_FindDepositsToSweep(t *testing.T) { err := log.SetLogLevel("*", "DEBUG") if err != nil { @@ -555,6 +634,109 @@ func TestFindDepositsToSweep_VaultGrouping(t *testing.T) { } }) + t.Run("taproot compatibility reveals deduplicated before grouping", func(t *testing.T) { + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + tbtcChain.SetBlockCounter(blockCounter) + tbtcChain.SetDepositMinAge(3600) + + vaultA := chain.Address("0xAA1122BB3344CC5566DD7788EE9900FF00112233") + + // Three ordinary legacy deposits form the largest valid group. + legacyHash1 := setupVaultGroupingDeposit( + t, tbtcChain, btcChain, walletPublicKeyHash, filterStartBlock, + "6611111111111111111111111111111111111111111111111111111111111111", + 0, 290000, &vaultA, + ) + legacyHash2 := setupVaultGroupingDeposit( + t, tbtcChain, btcChain, walletPublicKeyHash, filterStartBlock, + "6622222222222222222222222222222222222222222222222222222222222222", + 0, 290001, &vaultA, + ) + legacyHash3 := setupVaultGroupingDeposit( + t, tbtcChain, btcChain, walletPublicKeyHash, filterStartBlock, + "6633333333333333333333333333333333333333333333333333333333333333", + 0, 290002, &vaultA, + ) + + taprootHash1 := setupVaultGroupingDeposit( + t, tbtcChain, btcChain, walletPublicKeyHash, filterStartBlock, + "6644444444444444444444444444444444444444444444444444444444444444", + 0, 290003, &vaultA, + ) + taprootHash2 := setupVaultGroupingDeposit( + t, tbtcChain, btcChain, walletPublicKeyHash, filterStartBlock, + "6655555555555555555555555555555555555555555555555555555555555555", + 0, 290004, &vaultA, + ) + + for _, taprootDeposit := range []struct { + fundingTxHash bitcoin.Hash + blockNumber uint64 + }{ + {fundingTxHash: taprootHash1, blockNumber: 290003}, + {fundingTxHash: taprootHash2, blockNumber: 290004}, + } { + err := tbtcChain.AddPastTaprootDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.TaprootDepositRevealedEvent{ + BlockNumber: taprootDeposit.blockNumber, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: taprootDeposit.fundingTxHash, + FundingOutputIndex: 0, + }, + ) + if err != nil { + t.Fatal(err) + } + } + + task := tbtcpg.NewDepositSweepTask(tbtcChain, btcChain) + deposits, err := task.FindDepositsToSweep( + &testutils.MockLogger{}, + walletPublicKeyHash, + 10, + ) + if err != nil { + t.Fatal(err) + } + + if len(deposits) != 3 { + t.Fatalf("expected 3 legacy deposits, got %d", len(deposits)) + } + + expectedLegacyHashes := map[bitcoin.Hash]bool{ + legacyHash1: true, + legacyHash2: true, + legacyHash3: true, + } + taprootHashes := map[bitcoin.Hash]bool{ + taprootHash1: true, + taprootHash2: true, + } + + for _, deposit := range deposits { + if !expectedLegacyHashes[deposit.FundingTxHash] { + t.Errorf( + "unexpected non-legacy deposit selected: [%v]", + deposit.FundingTxHash, + ) + } + if taprootHashes[deposit.FundingTxHash] { + t.Errorf( + "taproot compatibility reveal selected in legacy group: [%v]", + deposit.FundingTxHash, + ) + } + } + }) + t.Run("mixed vaults minority group excluded", func(t *testing.T) { tbtcChain := tbtcpg.NewLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() diff --git a/pkg/tbtcpg/internal/test/marshaling.go b/pkg/tbtcpg/internal/test/marshaling.go index 91c390df6e..b44955e5d2 100644 --- a/pkg/tbtcpg/internal/test/marshaling.go +++ b/pkg/tbtcpg/internal/test/marshaling.go @@ -5,13 +5,13 @@ import ( "encoding/json" "errors" "fmt" - "github.com/keep-network/keep-core/pkg/tbtcpg" "math/big" "time" "github.com/keep-network/keep-core/internal/hexutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" + "github.com/keep-network/keep-core/pkg/tbtcpg" ) // UnmarshalJSON implements a custom JSON unmarshaling logic to produce a diff --git a/pkg/tbtcpg/moving_funds.go b/pkg/tbtcpg/moving_funds.go index 22a842abc8..544ef3187c 100644 --- a/pkg/tbtcpg/moving_funds.go +++ b/pkg/tbtcpg/moving_funds.go @@ -134,8 +134,12 @@ func (mft *MovingFundsTask) Run(request *tbtc.CoordinationProposalRequest) ( return nil, false, nil } - walletMainUtxo, err := tbtc.DetermineWalletMainUtxo( - walletPublicKeyHash, + if request.WalletPublicKey == nil { + return nil, false, fmt.Errorf("wallet public key is required") + } + + walletMainUtxo, err := tbtc.DetermineWalletMainUtxoForPublicKey( + request.WalletPublicKey, mft.chain, mft.btcChain, ) @@ -189,6 +193,7 @@ func (mft *MovingFundsTask) Run(request *tbtc.CoordinationProposalRequest) ( walletMemberIDs, walletMemberIndex, err := mft.GetWalletMembersInfo( request.WalletOperators, request.ExecutingOperator, + walletChainData.EcdsaWalletID == [32]byte{}, ) if err != nil { return nil, false, fmt.Errorf( @@ -439,6 +444,7 @@ func (mft *MovingFundsTask) retrieveCommittedTargetWallets( func (mft *MovingFundsTask) GetWalletMembersInfo( walletOperators []chain.Address, executingOperator chain.Address, + useFrostOperatorIDs bool, ) ([]uint32, uint32, error) { // Cache mapping operator addresses to their wallet member IDs. It helps to // limit the number of calls to the ETH client if some operator addresses @@ -465,7 +471,10 @@ func (mft *MovingFundsTask) GetWalletMembersInfo( // Search for the operator address in the cache. Store the operator // address in the cache if it's not there. if operatorID, found := operatorIDCache[operatorAddress]; !found { - fetchedOperatorID, err := mft.chain.GetOperatorID(operatorAddress) + fetchedOperatorID, err := mft.getOperatorID( + operatorAddress, + useFrostOperatorIDs, + ) if err != nil { return nil, 0, fmt.Errorf("failed to get operator ID: [%w]", err) } @@ -484,6 +493,17 @@ func (mft *MovingFundsTask) GetWalletMembersInfo( return walletMemberIDs, uint32(walletMemberIndex), nil } +func (mft *MovingFundsTask) getOperatorID( + operatorAddress chain.Address, + useFrostOperatorID bool, +) (chain.OperatorID, error) { + if useFrostOperatorID { + return mft.chain.GetFrostOperatorID(operatorAddress) + } + + return mft.chain.GetOperatorID(operatorAddress) +} + // SubmitMovingFundsCommitment submits the moving funds commitment and waits // until the transaction has entered the Ethereum blockchain. func (mft *MovingFundsTask) SubmitMovingFundsCommitment( diff --git a/pkg/tbtcpg/moving_funds_test.go b/pkg/tbtcpg/moving_funds_test.go index 4f24ae8ef7..228ea01fe8 100644 --- a/pkg/tbtcpg/moving_funds_test.go +++ b/pkg/tbtcpg/moving_funds_test.go @@ -331,7 +331,9 @@ func TestMovingFundsAction_FindTargetWallets_CommitmentAlreadySubmitted(t *testi func TestMovingFundsAction_GetWalletMembersInfo(t *testing.T) { var tests = map[string]struct { walletOperators []operatorInfo + frostOperatorIDs []operatorInfo executingOperator chain.Address + useFrostOperatorIDs bool expectedMemberIDs []uint32 expectedOperatorPosition uint32 expectedError error @@ -350,6 +352,28 @@ func TestMovingFundsAction_GetWalletMembersInfo(t *testing.T) { expectedOperatorPosition: 3, expectedError: nil, }, + "success case with FROST operator IDs": { + walletOperators: []operatorInfo{ + // Legacy IDs are intentionally different to prove the FROST + // sortition pool is used. + {"5df232b0348928793658dd05dfc6b05a59d11ae8", 30}, + {"dcc895d32b74b34cef2baa6546884fcda65da1e9", 10}, + {"28759deda2ea33bd72f68ea2e8f60cd670c2549f", 20}, + {"f7891d42f3c61a49e0aed1e31b151877c0905cf7", 40}, + {"28759deda2ea33bd72f68ea2e8f60cd670c2549f", 20}, + }, + frostOperatorIDs: []operatorInfo{ + {"5df232b0348928793658dd05dfc6b05a59d11ae8", 3}, + {"dcc895d32b74b34cef2baa6546884fcda65da1e9", 1}, + {"28759deda2ea33bd72f68ea2e8f60cd670c2549f", 2}, + {"f7891d42f3c61a49e0aed1e31b151877c0905cf7", 4}, + }, + executingOperator: "28759deda2ea33bd72f68ea2e8f60cd670c2549f", + useFrostOperatorIDs: true, + expectedMemberIDs: []uint32{3, 1, 2, 4, 2}, + expectedOperatorPosition: 3, + expectedError: nil, + }, "executing operator not among operators": { walletOperators: []operatorInfo{ {"5df232b0348928793658dd05dfc6b05a59d11ae8", 2}, @@ -379,10 +403,20 @@ func TestMovingFundsAction_GetWalletMembersInfo(t *testing.T) { } walletOperators = append(walletOperators, operatorInfo.Address) } + for _, operatorInfo := range test.frostOperatorIDs { + err := tbtcChain.SetFrostOperatorID( + operatorInfo.Address, + operatorInfo.OperatorID, + ) + if err != nil { + t.Fatal(err) + } + } memberIDs, operatorPosition, err := task.GetWalletMembersInfo( walletOperators, test.executingOperator, + test.useFrostOperatorIDs, ) if diff := deep.Equal(test.expectedMemberIDs, memberIDs); diff != nil { diff --git a/pkg/tbtcpg/redemptions.go b/pkg/tbtcpg/redemptions.go index 981e9a8eb7..59dec7a29c 100644 --- a/pkg/tbtcpg/redemptions.go +++ b/pkg/tbtcpg/redemptions.go @@ -383,8 +383,13 @@ redemptionRequestedLoop: }, ) - // Capture time now for computations. - timeNow := time.Now() + timeNow, err := chain.CurrentBlockTimestamp() + if err != nil { + return nil, fmt.Errorf( + "failed to get current block timestamp: [%w]", + err, + ) + } // Only redemption requests in range: // [now - requestTimeout, now - minAge] diff --git a/pkg/tbtcpg/redemptions_test.go b/pkg/tbtcpg/redemptions_test.go index 8f61e2f94b..140a5ae64e 100644 --- a/pkg/tbtcpg/redemptions_test.go +++ b/pkg/tbtcpg/redemptions_test.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "math/big" "testing" + "time" "github.com/go-test/deep" "github.com/keep-network/keep-core/internal/testutils" @@ -136,6 +137,95 @@ func TestRedemptionAction_FindPendingRedemptions(t *testing.T) { } } +func TestRedemptionAction_FindPendingRedemptions_UsesChainTimestamp(t *testing.T) { + currentBlock := uint64(100000) + averageBlockTime := 10 * time.Second + requestTimeout := uint32(86400) + requestMinAge := uint32(600) + chainNow := time.Now().Add(3 * time.Hour) + + walletPublicKeyHash := hexToByte20( + "7670343fc00ccc2d0cd65360e6ad400697ea0fed", + ) + redeemerOutputScript := bitcoin.Script{ + 0x00, 0x14, 0xe6, 0xf9, 0xd7, 0x47, 0x26, 0xb1, 0x9b, 0x75, + 0xf1, 0x6f, 0xe1, 0xe9, 0xfe, 0xae, 0xc0, 0x48, 0xaa, 0x4f, + 0xa1, 0xd0, + } + + tbtcChain := tbtcpg.NewLocalChain() + tbtcChain.SetAverageBlockTime(averageBlockTime) + tbtcChain.SetCurrentBlockTimestamp(chainNow) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + tbtcChain.SetBlockCounter(blockCounter) + + tbtcChain.SetRedemptionParameters( + 0, + 0, + 0, + 0, + requestTimeout, + nil, + 0, + ) + tbtcChain.SetRedemptionRequestMinAge(requestMinAge) + + requestTimeoutBlocks := uint64(requestTimeout) / + uint64(averageBlockTime.Seconds()) + + err := tbtcChain.AddPastRedemptionRequestedEvent( + &tbtc.RedemptionRequestedEventFilter{ + StartBlock: currentBlock - requestTimeoutBlocks - 1000, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.RedemptionRequestedEvent{ + WalletPublicKeyHash: walletPublicKeyHash, + RedeemerOutputScript: redeemerOutputScript, + RequestedAmount: 100000, + BlockNumber: 90000, + }, + ) + if err != nil { + t.Fatal(err) + } + + tbtcChain.SetPendingRedemptionRequest( + walletPublicKeyHash, + &tbtc.RedemptionRequest{ + RedeemerOutputScript: redeemerOutputScript, + RequestedAmount: 100000, + // This timestamp is deliberately in the future compared to the + // host clock, but mature compared to the chain clock. + RequestedAt: chainNow.Add(-(time.Duration(requestMinAge) + 1) * time.Second), + }, + ) + tbtcChain.SetRedemptionDelay( + walletPublicKeyHash, + redeemerOutputScript, + 0, + ) + + task := tbtcpg.NewRedemptionTask(tbtcChain, nil) + + redeemersOutputScripts, err := task.FindPendingRedemptions( + &testutils.MockLogger{}, + walletPublicKeyHash, + 1, + ) + if err != nil { + t.Fatal(err) + } + + if diff := deep.Equal( + []bitcoin.Script{redeemerOutputScript}, + redeemersOutputScripts, + ); diff != nil { + t.Errorf("invalid wallets pending redemptions: %v", diff) + } +} + func TestRedemptionAction_ProposeRedemption(t *testing.T) { fromHex := func(hexString string) []byte { bytes, err := hex.DecodeString(hexString)