diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index a0dbc11f3d..edb7791ae2 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -65,6 +65,25 @@ jobs: - './config/_electrum_urls/**' - './pkg/bitcoin/electrum/**' + client-risk-detect-changes: + runs-on: ubuntu-latest + outputs: + path-filter: ${{ steps.filter.outputs.path-filter }} + steps: + - uses: actions/checkout@v4 + if: github.event_name == 'pull_request' + + - uses: dorny/paths-filter@v2 + if: github.event_name == 'pull_request' + id: filter + with: + filters: | + path-filter: + - './pkg/covenantsigner/**' + - './pkg/tbtc/**' + - './pkg/chain/ethereum/**' + - './cmd/start.go' + client-build-test-publish: needs: client-detect-changes if: | @@ -301,6 +320,24 @@ jobs: install-go: false checks: "-SA1019" + client-race: + needs: client-risk-detect-changes + if: | + github.event_name == 'push' + || needs.client-risk-detect-changes.outputs.path-filter == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + - name: Race detector (high-risk packages) + run: | + go test -race -timeout 20m ./pkg/covenantsigner + go test -race -timeout 20m ./pkg/tbtc \ + -run '^(TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThreshold|TestValidateMigrationOutputValues_RejectsValuesExceedingInt64)$' \ + -count=1 + client-integration-test: needs: [electrum-integration-detect-changes, client-build-test-publish] if: | diff --git a/cmd/flags.go b/cmd/flags.go index 6ce094c2e6..e2e82e3d80 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -15,6 +15,7 @@ import ( "github.com/keep-network/keep-core/pkg/bitcoin/electrum" chainEthereum "github.com/keep-network/keep-core/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/keep-network/keep-core/pkg/maintainer/spv" "github.com/keep-network/keep-core/pkg/net/libp2p" "github.com/keep-network/keep-core/pkg/tbtc" @@ -46,6 +47,8 @@ func initFlags( initStorageFlags(cmd, cfg) case config.ClientInfo: initClientInfoFlags(cmd, cfg) + case config.CovenantSigner: + initCovenantSignerFlags(cmd, cfg) case config.Tbtc: initTbtcFlags(cmd, cfg) case config.Maintainer: @@ -310,6 +313,39 @@ func initTbtcFlags(cmd *cobra.Command, cfg *config.Config) { ) } +func initCovenantSignerFlags(cmd *cobra.Command, cfg *config.Config) { + cmd.Flags().IntVar( + &cfg.CovenantSigner.Port, + "covenantSigner.port", + covenantsigner.Config{}.Port, + "Covenant signer provider HTTP server listening port. Zero disables the service.", + ) + cmd.Flags().StringVar( + &cfg.CovenantSigner.ListenAddress, + "covenantSigner.listenAddress", + covenantsigner.DefaultListenAddress, + "Covenant signer provider HTTP listen address. Defaults to loopback-only.", + ) + cmd.Flags().StringVar( + &cfg.CovenantSigner.AuthToken, + "covenantSigner.authToken", + covenantsigner.Config{}.AuthToken, + "Covenant signer provider static Bearer auth token. Required for non-loopback binds; prefer config file or env var over CLI in production.", + ) + cmd.Flags().BoolVar( + &cfg.CovenantSigner.EnableSelfV1, + "covenantSigner.enableSelfV1", + false, + "Expose self_v1 covenant signer HTTP routes. Keep disabled for a qc_v1-first launch unless self_v1 is explicitly approved.", + ) + cmd.Flags().BoolVar( + &cfg.CovenantSigner.RequireApprovalTrustRoots, + "covenantSigner.requireApprovalTrustRoots", + false, + "Fail startup when enabled covenant routes are missing route-level approval trust roots. Request-time validation still enforces exact reserve/network trust-root matches.", + ) +} + // Initialize flags for Maintainer configuration. func initMaintainerFlags(command *cobra.Command, cfg *config.Config) { command.Flags().BoolVar( diff --git a/cmd/flags_test.go b/cmd/flags_test.go index 58ee1249ae..29ccddd53a 100644 --- a/cmd/flags_test.go +++ b/cmd/flags_test.go @@ -22,6 +22,7 @@ import ( ethereumEcdsa "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/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" + "github.com/keep-network/keep-core/pkg/covenantsigner" ) var cmdFlagsTests = map[string]struct { @@ -190,6 +191,41 @@ var cmdFlagsTests = map[string]struct { expectedValueFromFlag: 76 * time.Second, defaultValue: 10 * time.Minute, }, + "covenantSigner.port": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.Port }, + flagName: "--covenantSigner.port", + flagValue: "9711", + expectedValueFromFlag: 9711, + defaultValue: 0, + }, + "covenantSigner.listenAddress": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.ListenAddress }, + flagName: "--covenantSigner.listenAddress", + flagValue: "0.0.0.0", + expectedValueFromFlag: "0.0.0.0", + defaultValue: covenantsigner.DefaultListenAddress, + }, + "covenantSigner.authToken": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.AuthToken }, + flagName: "--covenantSigner.authToken", + flagValue: "secret-token", + expectedValueFromFlag: "secret-token", + defaultValue: "", + }, + "covenantSigner.enableSelfV1": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.EnableSelfV1 }, + flagName: "--covenantSigner.enableSelfV1", + flagValue: "", + expectedValueFromFlag: true, + defaultValue: false, + }, + "covenantSigner.requireApprovalTrustRoots": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.RequireApprovalTrustRoots }, + flagName: "--covenantSigner.requireApprovalTrustRoots", + flagValue: "", + expectedValueFromFlag: true, + defaultValue: false, + }, "tbtc.preParamsPoolSize": { readValueFunc: func(c *config.Config) interface{} { return c.Tbtc.PreParamsPoolSize }, flagName: "--tbtc.preParamsPoolSize", diff --git a/cmd/start.go b/cmd/start.go index cfaece274c..45a5b97101 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -5,10 +5,12 @@ import ( "fmt" "time" + commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/tbtcpg" "github.com/keep-network/keep-common/pkg/persistence" "github.com/keep-network/keep-core/build" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/bitcoin/electrum" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/storage" @@ -17,9 +19,11 @@ import ( "github.com/keep-network/keep-core/config" "github.com/keep-network/keep-core/pkg/beacon" + beaconchain "github.com/keep-network/keep-core/pkg/beacon/chain" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/keep-network/keep-core/pkg/firewall" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" @@ -45,6 +49,77 @@ var StartCommand = &cobra.Command{ }, } +type startDeps struct { + connectEthereum func( + ctx context.Context, + config commonEthereum.Config, + ) ( + *ethereum.BeaconChain, + *ethereum.TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, + ) + connectElectrum func( + ctx context.Context, + config electrum.Config, + ) (bitcoin.Chain, error) + initializeNetwork func( + ctx context.Context, + applications []firewall.Application, + operatorPrivateKey *operator.PrivateKey, + blockCounter chain.BlockCounter, + ) (net.Provider, error) + initializePersistence func() ( + beaconKeyStorePersistence persistence.ProtectedHandle, + tbtcKeyStorePersistence persistence.ProtectedHandle, + tbtcDataPersistence persistence.BasicHandle, + err error, + ) + initializeBeacon func( + ctx context.Context, + chain beaconchain.Interface, + netProvider net.Provider, + keyStorePersistence persistence.ProtectedHandle, + scheduler *generator.Scheduler, + ) error + initializeTbtc func( + ctx context.Context, + chain tbtc.Chain, + btcChain bitcoin.Chain, + netProvider net.Provider, + keyStorePersistance persistence.ProtectedHandle, + workPersistence persistence.BasicHandle, + scheduler *generator.Scheduler, + proposalGenerator tbtc.CoordinationProposalGenerator, + config tbtc.Config, + clientInfoRegistry *clientinfo.Registry, + perfMetrics *clientinfo.PerformanceMetrics, + minActiveOutpointConfirmations uint, + ) (covenantsigner.Engine, error) + initializeSigner func( + ctx context.Context, + config covenantsigner.Config, + handle persistence.BasicHandle, + engine covenantsigner.Engine, + ) (*covenantsigner.Server, bool, error) + startScheduler func() *generator.Scheduler +} + +func defaultStartDeps() startDeps { + return startDeps{ + connectEthereum: ethereum.Connect, + connectElectrum: electrum.Connect, + initializeNetwork: initializeNetwork, + initializePersistence: initializePersistence, + initializeBeacon: beacon.Initialize, + initializeTbtc: tbtc.Initialize, + initializeSigner: covenantsigner.Initialize, + startScheduler: generator.StartScheduler, + } +} + func init() { initFlags(StartCommand, &configFilePath, clientConfig, config.StartCmdCategories...) @@ -63,15 +138,19 @@ Environment variables: // start starts a node func start(cmd *cobra.Command) error { + return startWithDeps(cmd, defaultStartDeps()) +} + +func startWithDeps(cmd *cobra.Command, deps startDeps) error { ctx := context.Background() beaconChain, tbtcChain, blockCounter, signing, operatorPrivateKey, err := - ethereum.Connect(ctx, clientConfig.Ethereum) + deps.connectEthereum(ctx, clientConfig.Ethereum) if err != nil { return fmt.Errorf("error connecting to Ethereum node: [%v]", err) } - netProvider, err := initializeNetwork( + netProvider, err := deps.initializeNetwork( ctx, []firewall.Application{beaconChain, tbtcChain}, operatorPrivateKey, @@ -110,7 +189,7 @@ func start(cmd *cobra.Command) error { // Skip initialization for bootstrap nodes as they are only used for network // discovery. if !isBootstrap() { - btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Electrum) + btcChain, err := deps.connectElectrum(ctx, clientConfig.Bitcoin.Electrum) if err != nil { return fmt.Errorf("could not connect to Electrum chain: [%v]", err) } @@ -118,12 +197,12 @@ func start(cmd *cobra.Command) error { beaconKeyStorePersistence, tbtcKeyStorePersistence, tbtcDataPersistence, - err := initializePersistence() + err := deps.initializePersistence() if err != nil { return fmt.Errorf("cannot initialize persistence: [%w]", err) } - scheduler := generator.StartScheduler() + scheduler := deps.startScheduler() if clientInfoRegistry != nil { clientInfoRegistry.ObserveBtcConnectivity( @@ -142,7 +221,7 @@ func start(cmd *cobra.Command) error { rpcHealthChecker.Start(ctx) } - err = beacon.Initialize( + err = deps.initializeBeacon( ctx, beaconChain, netProvider, @@ -158,7 +237,7 @@ func start(cmd *cobra.Command) error { btcChain, ) - err = tbtc.Initialize( + covenantSignerEngine, err := deps.initializeTbtc( ctx, tbtcChain, btcChain, @@ -170,10 +249,21 @@ func start(cmd *cobra.Command) error { clientConfig.Tbtc, clientInfoRegistry, perfMetrics, // Pass the existing performance metrics instance to avoid duplicate registrations + clientConfig.CovenantSigner.MinActiveOutpointConfirmations, ) if err != nil { return fmt.Errorf("error initializing TBTC: [%v]", err) } + + _, _, err = deps.initializeSigner( + ctx, + clientConfig.CovenantSigner, + tbtcDataPersistence, + covenantSignerEngine, + ) + if err != nil { + return fmt.Errorf("error initializing covenant signer: [%v]", err) + } } nodeHeader( diff --git a/cmd/start_test.go b/cmd/start_test.go new file mode 100644 index 0000000000..effdfdb3e1 --- /dev/null +++ b/cmd/start_test.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "context" + "errors" + "strings" + "testing" + + commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" + "github.com/keep-network/keep-core/config" + "github.com/keep-network/keep-core/pkg/chain" + chainEthereum "github.com/keep-network/keep-core/pkg/chain/ethereum" + "github.com/keep-network/keep-core/pkg/firewall" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/spf13/cobra" +) + +func TestStartFailsFastWhenEthereumConnectFails(t *testing.T) { + originalConfig := *clientConfig + + t.Cleanup(func() { + *clientConfig = originalConfig + }) + + *clientConfig = config.Config{} + networkInitCalled := false + + deps := defaultStartDeps() + deps.connectEthereum = func( + _ context.Context, + _ commonEthereum.Config, + ) ( + *chainEthereum.BeaconChain, + *chainEthereum.TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, + ) { + return nil, nil, nil, nil, nil, errors.New("injected ethereum failure") + } + deps.initializeNetwork = func( + _ context.Context, + _ []firewall.Application, + _ *operator.PrivateKey, + _ chain.BlockCounter, + ) (net.Provider, error) { + networkInitCalled = true + return nil, nil + } + + err := startWithDeps(&cobra.Command{}, deps) + if err == nil || !strings.Contains(err.Error(), "error connecting to Ethereum node") { + t.Fatalf("expected ethereum connection failure, got: %v", err) + } + if networkInitCalled { + t.Fatal("expected network initialization not to run after ethereum connection failure") + } +} + +func TestStartFailsFastWhenNetworkInitializationFails(t *testing.T) { + originalConfig := *clientConfig + + t.Cleanup(func() { + *clientConfig = originalConfig + }) + + *clientConfig = config.Config{} + deps := defaultStartDeps() + deps.connectEthereum = func( + _ context.Context, + _ commonEthereum.Config, + ) ( + *chainEthereum.BeaconChain, + *chainEthereum.TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, + ) { + return nil, nil, nil, nil, nil, nil + } + + deps.initializeNetwork = func( + _ context.Context, + _ []firewall.Application, + _ *operator.PrivateKey, + _ chain.BlockCounter, + ) (net.Provider, error) { + return nil, errors.New("injected network initialization failure") + } + + err := startWithDeps(&cobra.Command{}, deps) + if err == nil || !strings.Contains(err.Error(), "cannot initialize network") { + t.Fatalf("expected network initialization failure, got: %v", err) + } +} diff --git a/config/category.go b/config/category.go index f6b3f2ab0c..4896afd854 100644 --- a/config/category.go +++ b/config/category.go @@ -9,6 +9,7 @@ const ( Network Storage ClientInfo + CovenantSigner Tbtc Maintainer Developer @@ -22,6 +23,7 @@ var StartCmdCategories = []Category{ Network, Storage, ClientInfo, + CovenantSigner, Tbtc, Developer, } @@ -41,6 +43,7 @@ var AllCategories = []Category{ Network, Storage, ClientInfo, + CovenantSigner, Tbtc, Maintainer, Developer, diff --git a/config/config.go b/config/config.go index 92081b2f10..b7183e143a 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,7 @@ import ( commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/bitcoin/electrum" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/keep-network/keep-core/pkg/maintainer" "github.com/keep-network/keep-core/pkg/net/libp2p" "github.com/keep-network/keep-core/pkg/storage" @@ -45,13 +46,14 @@ const ( // Config is the top level config structure. type Config struct { - Ethereum commonEthereum.Config - Bitcoin BitcoinConfig - LibP2P libp2p.Config `mapstructure:"network"` - Storage storage.Config - ClientInfo clientinfo.Config - Maintainer maintainer.Config - Tbtc tbtc.Config + Ethereum commonEthereum.Config + Bitcoin BitcoinConfig + LibP2P libp2p.Config `mapstructure:"network"` + Storage storage.Config + ClientInfo clientinfo.Config + CovenantSigner covenantsigner.Config + Maintainer maintainer.Config + Tbtc tbtc.Config } // BitcoinConfig defines the configuration for Bitcoin. diff --git a/config/config_test.go b/config/config_test.go index 26d8a74fea..a29da7d9fd 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -199,6 +199,14 @@ func TestReadConfigFromFile(t *testing.T) { readValueFunc: func(c *Config) interface{} { return c.ClientInfo.EthereumMetricsTick }, expectedValue: 87 * time.Second, }, + "CovenantSigner.Port": { + readValueFunc: func(c *Config) interface{} { return c.CovenantSigner.Port }, + expectedValue: 9702, + }, + "CovenantSigner.RequireApprovalTrustRoots": { + readValueFunc: func(c *Config) interface{} { return c.CovenantSigner.RequireApprovalTrustRoots }, + expectedValue: true, + }, "Maintainer.BitcoinDifficulty.Enabled": { readValueFunc: func(c *Config) interface{} { return c.Maintainer.BitcoinDifficulty.Enabled }, expectedValue: true, diff --git a/pkg/bitcoin/transaction_builder.go b/pkg/bitcoin/transaction_builder.go index e446f07517..69c746980a 100644 --- a/pkg/bitcoin/transaction_builder.go +++ b/pkg/bitcoin/transaction_builder.go @@ -147,6 +147,13 @@ func (tb *TransactionBuilder) getScript( err, ) } + if int(utxo.Outpoint.OutputIndex) >= len(transaction.Outputs) { + return nil, fmt.Errorf( + "output index [%d] out of bounds for transaction with [%d] outputs", + utxo.Outpoint.OutputIndex, + len(transaction.Outputs), + ) + } return transaction.Outputs[utxo.Outpoint.OutputIndex].PublicKeyScript, nil } @@ -156,6 +163,41 @@ func (tb *TransactionBuilder) AddOutput(output *TransactionOutput) { tb.internal.AddTxOut(wire.NewTxOut(output.Value, output.PublicKeyScript)) } +// SetInputSequence overrides the sequence number for the input at the given +// index. +func (tb *TransactionBuilder) SetInputSequence(index int, sequence uint32) error { + if index < 0 || index >= len(tb.internal.TxIn) { + return fmt.Errorf("wrong input index") + } + + tb.internal.TxIn[index].Sequence = sequence + + return nil +} + +// SetInputWitness overrides the witness stack for the input at the given +// index. +func (tb *TransactionBuilder) SetInputWitness(index int, witness [][]byte) error { + if index < 0 || index >= len(tb.internal.TxIn) { + return fmt.Errorf("wrong input index") + } + + tb.internal.TxIn[index].Witness = witness + tb.internal.TxIn[index].SignatureScript = nil + + return nil +} + +// SetLocktime overrides the transaction locktime. +func (tb *TransactionBuilder) SetLocktime(locktime uint32) { + tb.internal.LockTime = locktime +} + +// Build returns the transaction in its current state. +func (tb *TransactionBuilder) Build() *Transaction { + return tb.internal.toTransaction() +} + // ComputeSignatureHashes computes the signature hashes for all transaction // inputs and stores them into the builder's state. Elements of the returned // slice are ordered in the same way as the transaction inputs they correspond diff --git a/pkg/bitcoin/transaction_builder_test.go b/pkg/bitcoin/transaction_builder_test.go index 246e70cd51..b35b1245ed 100644 --- a/pkg/bitcoin/transaction_builder_test.go +++ b/pkg/bitcoin/transaction_builder_test.go @@ -215,6 +215,49 @@ func TestTransactionBuilder_AddOutput(t *testing.T) { assertInternalOutput(t, builder, 0, output) } +func TestTransactionBuilder_SetInputSequenceWitnessAndLocktime(t *testing.T) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + inputTransaction := transactionFrom(t, "01000000000101a0367a0790e3dfc199df34ca9ce5c35591510b6525d2d5869166728a5ed554be0100000000ffffffff02e02e00000000000022002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca962cff100000000000160014e257eccafbc07c381642ce6e7e55120fb077fbed02473044022050759dde2c84bccf3c1502b0e33a6acb570117fd27a982c0c2991c9f9737508e02201fcba5d6f6c0ab780042138a9110418b3f589d8d09a900f20ee28cfcdb14d2970121039d61d62dcd048d3f8550d22eb90b4af908db60231d117aeede04e7bc11907bfa00000000") + if err := localChain.addTransaction(inputTransaction); err != nil { + t.Fatal(err) + } + + utxo := &UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: inputTransaction.Hash(), + OutputIndex: 0, + }, + Value: 12000, + } + redeemScript := hexToSlice(t, "14934b98637ca318a4d6e7ca6ffd1690b8e77df6377508f9f0c90d000395237576a9148db50eb52063ea9d98b3eac91489a90f738986f68763ac6776a914e257eccafbc07c381642ce6e7e55120fb077fbed8804e0250162b175ac68") + + if err := builder.AddScriptHashInput(utxo, redeemScript); err != nil { + t.Fatal(err) + } + if err := builder.SetInputSequence(0, 0xfffffffd); err != nil { + t.Fatal(err) + } + if err := builder.SetInputWitness(0, [][]byte{{0x01}, {0x02}, redeemScript}); err != nil { + t.Fatal(err) + } + builder.SetLocktime(12345) + + assertInternalInput(t, builder, 0, &TransactionInput{ + Outpoint: utxo.Outpoint, + SignatureScript: nil, + Witness: [][]byte{{0x01}, {0x02}, redeemScript}, + Sequence: 0xfffffffd, + }) + + transaction := builder.Build() + testutils.AssertIntsEqual(t, "locktime", 12345, int(transaction.Locktime)) + if !reflect.DeepEqual(transaction.Inputs[0].Witness, [][]byte{{0x01}, {0x02}, redeemScript}) { + t.Fatal("unexpected built transaction witness") + } +} + // 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 diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index ec5c29d40f..1f750abaec 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1463,8 +1463,18 @@ func (tc *TbtcChain) GetWallet( // Wallet not found. if wallet.CreatedAt == 0 { return nil, fmt.Errorf( - "no wallet for public key hash [0x%x]", - wallet, + "%w for public key hash [0x%x]", + tbtc.ErrWalletNotFound, + walletPublicKeyHash, + ) + } + + walletRegistryWallet, err := tc.walletRegistry.GetWallet(wallet.EcdsaWalletID) + if err != nil { + return nil, fmt.Errorf( + "cannot get wallet registry data for wallet [0x%x]: [%v]", + wallet.EcdsaWalletID, + err, ) } @@ -1475,6 +1485,7 @@ func (tc *TbtcChain) GetWallet( return &tbtc.WalletChainData{ EcdsaWalletID: wallet.EcdsaWalletID, + MembersIDsHash: walletRegistryWallet.MembersIdsHash, MainUtxoHash: wallet.MainUtxoHash, PendingRedemptionsValue: wallet.PendingRedemptionsValue, CreatedAt: time.Unix(int64(wallet.CreatedAt), 0), diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 1c9eef1be0..e6c77914e3 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -7,6 +7,7 @@ import ( "fmt" "math/big" "reflect" + "strings" "testing" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -14,6 +15,7 @@ import ( "github.com/keep-network/keep-core/pkg/chain" "github.com/ethereum/go-ethereum/common" + commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/chain/local_v1" @@ -533,3 +535,34 @@ func TestBuildMovedFundsKey(t *testing.T) { movedFundsKey.Text(16), ) } + +func TestNewTbtcChainRejectsMissingBridgeContractAddress(t *testing.T) { + _, err := newTbtcChain( + commonEthereum.Config{ + ContractAddresses: map[string]string{}, + }, + nil, + ) + if err == nil || !strings.Contains(err.Error(), "failed to resolve Bridge contract address") { + t.Fatalf("expected bridge contract address resolution error, got: %v", err) + } +} + +func TestNewTbtcChainRejectsMalformedBridgeContractAddress(t *testing.T) { + config := commonEthereum.Config{ + ContractAddresses: map[string]string{}, + } + config.SetContractAddress(BridgeContractName, "not-a-hex-address") + + _, err := newTbtcChain(config, nil) + if err == nil || !strings.Contains(err.Error(), "failed to resolve Bridge contract address") { + t.Fatalf("expected malformed bridge contract address error, got: %v", err) + } +} + +func TestParseWalletStateRejectsUnsupportedValue(t *testing.T) { + _, err := parseWalletState(255) + if err == nil || !strings.Contains(err.Error(), "unexpected wallet state value") { + t.Fatalf("expected unsupported wallet state error, got: %v", err) + } +} diff --git a/pkg/covenantsigner/DEPLOYMENT.md b/pkg/covenantsigner/DEPLOYMENT.md new file mode 100644 index 0000000000..58c84dcba8 --- /dev/null +++ b/pkg/covenantsigner/DEPLOYMENT.md @@ -0,0 +1,215 @@ +# Covenant Signer Deployment Topology + +This document describes deployment topology constraints, coordination +mechanisms, and operational considerations for the covenant signer subsystem. +Source file references use `file:function` or `file:line` notation relative +to `pkg/covenantsigner/` and `pkg/tbtc/` within the repository root. + +## 1. Expected Deployment Topology + +The covenant signer is designed around a **single-node-per-wallet** deployment +model. Each covenant signer node controls the signing key shares for exactly +one wallet through its local `walletRegistry`. + +When a signing request arrives, the engine calls `node.go:getSigningExecutor` +(line 319) to resolve the signing executor from the node's local wallet +registry. This function checks `walletRegistry.getSigners(walletPublicKey)` +(line 340) and returns `(nil, false, nil)` when the node holds no signer +shares for the requested wallet (lines 341-344), causing the engine to +reject the request without error (see Section 5 for details). + +Each node runs its own HTTP server via `server.go:Initialize` (line 30), +binding to a configurable address and port (`server.go`, line 107). The +server maintains its own request store, authentication state, and signing +executor cache. No state is shared between nodes. + +Multi-node deployments are possible when multiple nodes hold signer shares +for the same wallet, which is inherent to the threshold signing architecture. +However, this topology introduces coordination challenges documented in the +following sections. + +## 2. Load Balancer Requirements + +If multiple covenant signer nodes serve the same wallet and are placed behind +a single base URL, the load balancer **must use sticky sessions or +single-target routing**. + +**Why this is required:** + +Request deduplication is node-local (see Section 3 for full details). If a +load balancer distributes requests with the same `routeRequestID` across +different nodes, each node independently creates a new signing job for that +request, producing duplicate signing sessions for the same covenant +operation. + +The Submit idempotency mechanism in `service.go:Submit` (line 254) checks +`store.GetByRouteRequest(route, routeRequestID)` to detect duplicate +requests. This lookup hits an in-memory map local to the process. A second +node has no visibility into the first node's store. + +**Timeout considerations:** + +The HTTP server is configured with a 30-second write timeout +(`server.go`, line 111). Load balancer health check intervals and upstream +timeout settings should account for this value to avoid premature connection +termination during signing operations. + +**Authentication:** + +Bearer token authentication (`server.go:withBearerAuth`, line 264) is +enforced for all non-loopback listen addresses. When running multiple nodes +behind the same load balancer endpoint, the `authToken` configuration must +be identical across all nodes. + +## 3. Request Deduplication Scope + +Request deduplication is **node-local only**. It prevents the same node from +creating multiple jobs for the same `routeRequestID`, but provides no +cross-node protection. + +### Deduplication components + +The deduplication logic in `service.go:Submit` (lines 253-266) relies on +three mechanisms: + +1. **`Service.mutex`** (`service.go`, line 20): A `sync.Mutex` that + serializes the check-and-insert critical section within `Submit()`. This + is an in-process lock with no distributed coordination. + +2. **`store.GetByRouteRequest()`** (`store.go`, line 152): Looks up existing + jobs by `route + routeRequestID` in the `Store.byRouteKey` in-memory map + (`store.go`, lines 17-18). + +3. **`requestDigest` comparison** (`service.go`, line 258): Verifies payload + consistency when a matching `routeRequestID` is found. + +### Deduplication flow in `Submit()` + +1. Acquire `s.mutex.Lock()` (line 253). +2. Call `s.store.GetByRouteRequest(route, input.RouteRequestID)` (line 254). +3. If found and digest matches: return the existing result idempotently + (lines 264-265). +4. If found and digest differs: return an `inputError` indicating payload + mismatch (lines 258-262). +5. If not found: create a new job, persist via `store.Put()` (line 301), + then release the lock (line 305). + +### Cross-node limitations + +- The `sync.Mutex` is an in-process lock. Separate processes, even on the + same host, maintain independent locks. +- The `Store` maps (`byRequestID`, `byRouteKey`) are in-memory per-process + (`store.go`, lines 17-18). +- File persistence uses `persistence.BasicHandle`, which writes JSON files + under `covenant-signer/jobs/` on the local filesystem with no cross-node + synchronization. + +**Consequence:** Multiple nodes behind a load balancer can produce duplicate +signing sessions for the same `routeRequestID` when requests are routed to +different nodes. This can trigger the P2P broadcast channel conflicts +described in Section 4. + +## 4. P2P Signing Session Convergence + +When a covenant signing request is accepted, the engine initiates a threshold +signing session over a P2P broadcast channel shared by all group members. +This section describes the signing flow and its behavior when multiple nodes +attempt concurrent signing for the same wallet. + +### Signing flow + +1. `covenantSignerEngine.submitSelfV1` / `submitQcV1` + (`covenant_signer.go`, lines 206 / 272) obtain a `signingExecutor` and + call `signBatch()`. + +2. `signingExecutor.signBatch()` (`signing.go`, line 104) processes messages + sequentially, calling `sign()` for each message in the batch. + +3. `sign()` (`signing.go`, line 186) acquires a `semaphore.Weighted(1)` lock + via `TryAcquire(1)` (line 191). This prevents concurrent signing for the + same wallet on the same node. If the lock is not available, the call + returns `errSigningExecutorBusy`. + +4. Each signer controlled by the node runs a goroutine (`signing.go`, + lines 238-403) that enters a retry loop with block-based coordination. + +5. The P2P broadcast channel is wallet-scoped: all nodes holding signers for + a given wallet share the channel named + `{ProtocolName}-{walletPublicKeyHex}` (`node.go`, lines 351-355). + +### Multi-node concurrent signing behavior + +If two nodes receive the same signing request -- for example, due to load +balancer misconfiguration (Section 2) or missing cross-node deduplication +(Section 3) -- both attempt to initiate signing sessions on the same P2P +broadcast channel. + +- The signing protocol uses an `announcer` and `signingDoneCheck` for group + coordination (`signing.go`, lines 245-255). These mechanisms help members + discover ongoing sessions and confirm completion. + +- Threshold signing can converge if enough group members participate in a + single session. However, conflicting concurrent sessions from different + initiators may cause confusion in the broadcast channel, leading to wasted + signing attempts or outright signing failures. + +- The `semaphore.Weighted(1)` lock (`signing.go`, line 85) prevents a single + node from running multiple signing sessions concurrently for the same + wallet, but it does not coordinate across nodes. + +### Retry and timing + +- `signingAttemptsLimit = 5` (`node.go`, line 43) bounds each signer to a + maximum of five retry attempts per message. +- `signingBatchInterludeBlocks = 2` (`signing.go`, line 36) inserts a + 2-block delay between sequential batch messages, giving signing done + messages time to propagate across the broadcast channel before the next + signing begins. + +## 5. Wallet Ownership Guard + +The covenant signer engine includes a wallet ownership check that prevents +nodes without signer shares from attempting to sign. This guard is +**necessary but not sufficient** for safe multi-node operation. + +### How the guard works + +Both `submitSelfV1()` (`covenant_signer.go`, line 220) and `submitQcV1()` +(`covenant_signer.go`, line 286) call +`cse.node.getSigningExecutor(walletPublicKey)`. + +`getSigningExecutor()` (`node.go`, line 319) checks +`n.walletRegistry.getSigners(walletPublicKey)` (line 340). When +`len(signers) == 0`, the function returns `(nil, false, nil)` (lines +341-344), indicating the node does not control the requested wallet without +raising an error. + +When the signing executor is not found, the engine returns +`ReasonPolicyRejected: "wallet is not controlled by this node"` +(`covenant_signer.go`, lines 224-225 and 290-291). + +### Why this is necessary but not sufficient + +**Necessary:** The guard prevents nodes that hold no signer shares for a +wallet from attempting to sign. Without it, any node receiving a request +could attempt to initiate a signing session, even if it has no key material +to contribute. This avoids unauthorized signing attempts and wasted +resources. + +**Not sufficient:** In a threshold signing scheme, multiple nodes +legitimately hold signer shares for the same wallet, and +`getSigningExecutor` returns `true` for all of them. Without external +coordination -- such as sticky load balancer routing (Section 2), cross-node +request deduplication (Section 3), or an explicit leader election mechanism +-- multiple nodes may independently accept and begin processing the same +signing request, leading to the concurrent session conflicts described in +Section 4. + +### Design assumption + +The covenant signer is designed for a topology where signing requests for a +given wallet are directed to a single node that controls that wallet's +signing shares. External routing logic (load balancer configuration, +deployment topology, or application-level request routing) is expected to +maintain this invariant. The `getSigningExecutor` guard provides a safety net +against misconfigured routing but does not replace it. diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go new file mode 100644 index 0000000000..16ede9a4f9 --- /dev/null +++ b/pkg/covenantsigner/config.go @@ -0,0 +1,42 @@ +package covenantsigner + +const DefaultListenAddress = "127.0.0.1" + +// Config configures the covenant signer HTTP service. +type Config struct { + // Port enables the covenant signer provider HTTP surface when non-zero. + Port int + // ListenAddress controls which interface the covenant signer HTTP service + // binds to. Empty defaults to loopback-only. + ListenAddress string + // AuthToken enables static Bearer authentication for signer endpoints. + // Non-loopback binds must set this. + AuthToken string + // EnableSelfV1 exposes the self_v1 signer HTTP routes. Keep this disabled + // for a qc_v1-first launch unless self_v1 has cleared its own go-live gate. + EnableSelfV1 bool + // RequireApprovalTrustRoots turns missing route-level approval trust roots + // from startup warnings into startup errors. This does not prove every + // reserve/network launch scope is provisioned; request-time validation still + // enforces exact route/reserve/network matches for configured entries. + RequireApprovalTrustRoots bool `mapstructure:"requireApprovalTrustRoots"` + // MigrationPlanQuoteTrustRoots configures the destination-service plan-quote + // trust roots used to verify migration plan quotes when the quote authority + // path is enabled. + MigrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot `mapstructure:"migrationPlanQuoteTrustRoots"` + // DepositorTrustRoots configures independently pinned depositor public keys + // by route/reserve/network for self_v1 approval verification. + DepositorTrustRoots []DepositorTrustRoot `mapstructure:"depositorTrustRoots"` + // CustodianTrustRoots configures independently pinned custodian public keys + // by route/reserve/network for qc_v1 approval verification. + CustodianTrustRoots []CustodianTrustRoot `mapstructure:"custodianTrustRoots"` + // MinActiveOutpointConfirmations sets the minimum number of Bitcoin + // confirmations required for an active outpoint transaction before the + // covenant signer accepts it. When zero (unset), the system defaults to 6 + // to align with the deposit sweep finality threshold. + MinActiveOutpointConfirmations uint `mapstructure:"minActiveOutpointConfirmations"` + // DataDir is the base directory path used by the disk persistence handle. + // When set, the store acquires an exclusive file lock to prevent concurrent + // process corruption. When empty, file locking is skipped. + DataDir string `mapstructure:"dataDir"` +} diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go new file mode 100644 index 0000000000..78c6556dc3 --- /dev/null +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -0,0 +1,3344 @@ +package covenantsigner + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/sha256" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec" + "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/internal/canonicaljson" +) + +type memoryDescriptor struct { + name string + directory string + content []byte +} + +func (md *memoryDescriptor) Name() string { return md.name } +func (md *memoryDescriptor) Directory() string { return md.directory } +func (md *memoryDescriptor) Content() ([]byte, error) { + return md.content, nil +} + +type memoryHandle struct { + items map[string]*memoryDescriptor +} + +func newMemoryHandle() *memoryHandle { + return &memoryHandle{items: make(map[string]*memoryDescriptor)} +} + +func (mh *memoryHandle) key(directory, name string) string { + return directory + "/" + name +} + +func (mh *memoryHandle) Save(data []byte, directory string, name string) error { + mh.items[mh.key(directory, name)] = &memoryDescriptor{ + name: name, + directory: directory, + content: append([]byte{}, data...), + } + return nil +} + +func (mh *memoryHandle) Delete(directory string, name string) error { + delete(mh.items, mh.key(directory, name)) + return nil +} + +func (mh *memoryHandle) ReadAll() (<-chan persistence.DataDescriptor, <-chan error) { + dataChan := make(chan persistence.DataDescriptor, len(mh.items)) + errorChan := make(chan error) + for _, item := range mh.items { + dataChan <- item + } + close(dataChan) + close(errorChan) + return dataChan, errorChan +} + +type faultingMemoryHandle struct { + *memoryHandle + saveErrByName map[string]error + deleteErrByName map[string]error +} + +func newFaultingMemoryHandle() *faultingMemoryHandle { + return &faultingMemoryHandle{ + memoryHandle: newMemoryHandle(), + saveErrByName: make(map[string]error), + deleteErrByName: make(map[string]error), + } +} + +func (fmh *faultingMemoryHandle) Save(data []byte, directory string, name string) error { + if err, ok := fmh.saveErrByName[name]; ok { + return err + } + + return fmh.memoryHandle.Save(data, directory, name) +} + +func (fmh *faultingMemoryHandle) Delete(directory string, name string) error { + if err, ok := fmh.deleteErrByName[name]; ok { + return err + } + + return fmh.memoryHandle.Delete(directory, name) +} + +type scriptedEngine struct { + submit func(*Job) (*Transition, error) + poll func(*Job) (*Transition, error) +} + +func (se *scriptedEngine) OnSubmit(_ context.Context, job *Job) (*Transition, error) { + if se.submit == nil { + return nil, nil + } + return se.submit(job) +} + +func (se *scriptedEngine) OnPoll(_ context.Context, job *Job) (*Transition, error) { + if se.poll == nil { + return nil, nil + } + return se.poll(job) +} + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +type approvalContractVector struct { + CanonicalSubmitRequest json.RawMessage `json:"canonicalSubmitRequest"` + ExpectedApprovalDigest string `json:"expectedApprovalDigest"` + ExpectedRequestDigest string `json:"expectedRequestDigest"` +} + +type approvalContractVectorsFile struct { + Version int `json:"version"` + Scope string `json:"scope"` + Vectors map[string]approvalContractVector `json:"vectors"` +} + +type migrationPlanQuoteSigningVector struct { + UnsignedQuote MigrationDestinationPlanQuote `json:"unsignedQuote"` + ExpectedPayload string `json:"expectedPayload"` + ExpectedPreimage string `json:"expectedPreimage"` + ExpectedHash string `json:"expectedHash"` + ExpectedSignature string `json:"expectedSignature"` +} + +type migrationPlanQuoteSigningVectorsFile struct { + Version int `json:"version"` + Scope string `json:"scope"` + TrustRoot MigrationPlanQuoteTrustRoot `json:"trustRoot"` + Vectors map[string]migrationPlanQuoteSigningVector `json:"vectors"` +} + +func loadApprovalContractVector( + t *testing.T, + key string, +) (RouteSubmitRequest, string, string) { + t.Helper() + + data, err := os.ReadFile("testdata/covenant_recovery_approval_vectors_v1.json") + if err != nil { + t.Fatal(err) + } + + vectors := approvalContractVectorsFile{} + if err := strictUnmarshal(data, &vectors); err != nil { + t.Fatal(err) + } + if vectors.Version != 1 { + t.Fatalf("unexpected vector version: %d", vectors.Version) + } + if vectors.Scope != "covenant_recovery_approval_contract_v1" { + t.Fatalf("unexpected vector scope: %s", vectors.Scope) + } + + vector, ok := vectors.Vectors[key] + if !ok { + t.Fatalf("missing vector %s", key) + } + + request := RouteSubmitRequest{} + if err := strictUnmarshal(vector.CanonicalSubmitRequest, &request); err != nil { + t.Fatal(err) + } + + return request, vector.ExpectedApprovalDigest, vector.ExpectedRequestDigest +} + +func loadMigrationPlanQuoteSigningVectors( + t *testing.T, +) migrationPlanQuoteSigningVectorsFile { + t.Helper() + + data, err := os.ReadFile("testdata/migration_plan_quote_signing_vectors_v1.json") + if err != nil { + t.Fatal(err) + } + + vectors := migrationPlanQuoteSigningVectorsFile{} + if err := strictUnmarshal(data, &vectors); err != nil { + t.Fatal(err) + } + + return vectors +} + +const ( + testDepositorPrivateKeyHex = "0x1111111111111111111111111111111111111111111111111111111111111111" + testSignerPrivateKeyHex = "0x2222222222222222222222222222222222222222222222222222222222222222" + testCustodianPrivateKeyHex = "0x3333333333333333333333333333333333333333333333333333333333333333" +) + +var ( + testDepositorPrivateKey = mustDeterministicTestPrivateKey(testDepositorPrivateKeyHex) + testSignerPrivateKey = mustDeterministicTestPrivateKey(testSignerPrivateKeyHex) + testCustodianPrivateKey = mustDeterministicTestPrivateKey(testCustodianPrivateKeyHex) + testDepositorPublicKey = mustCompressedPublicKeyHex(testDepositorPrivateKey) + testSignerPublicKey = mustCompressedPublicKeyHex(testSignerPrivateKey) + testSignerUncompressedPublicKey = mustUncompressedPublicKeyHex(testSignerPrivateKey) + testCustodianPublicKey = mustCompressedPublicKeyHex(testCustodianPrivateKey) + testMigrationPlanQuoteSeed = bytes.Repeat([]byte{0x44}, ed25519.SeedSize) + testMigrationPlanQuotePrivateKey = ed25519.NewKeyFromSeed(testMigrationPlanQuoteSeed) + testMigrationPlanQuoteTrustRoot = MigrationPlanQuoteTrustRoot{ + KeyID: "test-plan-quote-key", + PublicKeyPEM: mustMigrationPlanQuoteTrustRootPEM(testMigrationPlanQuotePrivateKey.Public().(ed25519.PublicKey)), + } +) + +func testDepositorTrustRoot(route TemplateID) DepositorTrustRoot { + migrationDestination := validMigrationDestination() + + return DepositorTrustRoot{ + Route: route, + Reserve: migrationDestination.Reserve, + Network: migrationDestination.Network, + PublicKey: testDepositorPublicKey, + } +} + +func testCustodianTrustRoot(route TemplateID) CustodianTrustRoot { + migrationDestination := validMigrationDestination() + + return CustodianTrustRoot{ + Route: route, + Reserve: migrationDestination.Reserve, + Network: migrationDestination.Network, + PublicKey: testCustodianPublicKey, + } +} + +func mustDeterministicTestPrivateKey(encoded string) *btcec.PrivateKey { + rawPrivateKey, err := hex.DecodeString(strings.TrimPrefix(encoded, "0x")) + if err != nil { + panic(err) + } + + privateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), rawPrivateKey) + return privateKey +} + +func mustCompressedPublicKeyHex(privateKey *btcec.PrivateKey) string { + return "0x" + hex.EncodeToString(privateKey.PubKey().SerializeCompressed()) +} + +func mustUncompressedPublicKeyHex(privateKey *btcec.PrivateKey) string { + return "0x" + hex.EncodeToString(privateKey.PubKey().SerializeUncompressed()) +} + +func mustMigrationPlanQuoteTrustRootPEM(publicKey ed25519.PublicKey) string { + encodedPublicKey, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + panic(err) + } + + return string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: encodedPublicKey, + })) +} + +func mustArtifactApprovalSignature( + privateKey *btcec.PrivateKey, + payload ArtifactApprovalPayload, +) string { + digest, err := artifactApprovalDigest(payload) + if err != nil { + panic(err) + } + + signature, err := privateKey.Sign(digest) + if err != nil { + panic(err) + } + + return "0x" + hex.EncodeToString(signature.Serialize()) +} + +func mustHighSCompactVariantSignature(signature string) string { + rawSignature, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) + if err != nil { + panic(err) + } + + parsedSignature, err := btcec.ParseDERSignature(rawSignature, btcec.S256()) + if err != nil { + panic(err) + } + + highS := new(big.Int).Sub(btcec.S256().N, parsedSignature.S) + rBytes := parsedSignature.R.Bytes() + sBytes := highS.Bytes() + if len(rBytes) > 32 || len(sBytes) > 32 { + panic("invalid compact signature component length") + } + + compact := make([]byte, 64) + copy(compact[32-len(rBytes):32], rBytes) + copy(compact[64-len(sBytes):64], sBytes) + + return "0x" + hex.EncodeToString(compact) +} + +func artifactApprovalSignatureByRole( + artifactApprovals *ArtifactApprovalEnvelope, + role ArtifactApprovalRole, +) string { + for _, approval := range artifactApprovals.Approvals { + if approval.Role == role { + return approval.Signature + } + } + + panic(fmt.Sprintf("missing approval role %s", role)) +} + +func setArtifactApprovalSignature( + artifactApprovals *ArtifactApprovalEnvelope, + role ArtifactApprovalRole, + signature string, +) { + for i, approval := range artifactApprovals.Approvals { + if approval.Role == role { + artifactApprovals.Approvals[i].Signature = signature + return + } + } + + panic(fmt.Sprintf("missing approval role %s", role)) +} + +func canonicalArtifactSignatures( + route TemplateID, + artifactApprovals *ArtifactApprovalEnvelope, +) []string { + if artifactApprovals == nil { + return nil + } + + requiredRoles, err := requiredStructuredArtifactApprovalRoles(route) + if err != nil { + panic(err) + } + + signatures := make([]string, len(requiredRoles)) + for i, role := range requiredRoles { + signatures[i] = artifactApprovalSignatureByRole(artifactApprovals, role) + } + + return signatures +} + +func canonicalArtifactSignaturesWithSignerApproval( + route TemplateID, + artifactApprovals *ArtifactApprovalEnvelope, + signerApproval *SignerApprovalCertificate, +) []string { + requiredRoles, err := requiredStructuredArtifactApprovalRoles(route) + if err != nil { + panic(err) + } + + signatures := make([]string, 0, len(requiredRoles)+1) + for _, role := range requiredRoles { + signatures = append( + signatures, + artifactApprovalSignatureByRole(artifactApprovals, role), + ) + } + if signerApproval == nil { + return signatures + } + + return append(signatures, signerApproval.Signature) +} + +func validSelfTemplate() json.RawMessage { + return mustTemplate(SelfV1Template{ + Template: TemplateSelfV1, + DepositorPublicKey: testDepositorPublicKey, + SignerPublicKey: testSignerPublicKey, + Delta2: 4320, + }) +} + +func validQcTemplate() json.RawMessage { + return mustTemplate(QcV1Template{ + Template: TemplateQcV1, + DepositorPublicKey: testDepositorPublicKey, + CustodianPublicKey: testCustodianPublicKey, + SignerPublicKey: testSignerPublicKey, + Beta: 144, + Delta2: 4320, + }) +} + +func mustTemplate(value any) json.RawMessage { + data, _ := json.Marshal(value) + return data +} + +func baseRequest(route TemplateID) RouteSubmitRequest { + migrationDestination := validMigrationDestination() + request := RouteSubmitRequest{ + FacadeRequestID: "rf_123", + IdempotencyKey: "idem_123", + RequestType: RequestTypeReconstruct, + Route: route, + Strategy: "0x1234", + Reserve: migrationDestination.Reserve, + Epoch: 12, + MaturityHeight: 912345, + ActiveOutpoint: CovenantOutpoint{TxID: "0x0102", Vout: 1, ScriptHash: "0x0304"}, + DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, + MigrationDestination: migrationDestination, + MigrationTransactionPlan: &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, + InputValueSats: 1000000, + DestinationValueSats: 998000, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 1670, + InputSequence: canonicalCovenantInputSequence, + LockTime: 912345, + }, + Artifacts: map[RecoveryPathID]ArtifactRecord{}, + } + + switch route { + case TemplateSelfV1: + request.ScriptTemplate = validSelfTemplate() + request.Signing = SigningRequirements{SignerRequired: true, CustodianRequired: false} + case TemplateQcV1: + request.ScriptTemplate = validQcTemplate() + request.Signing = SigningRequirements{SignerRequired: true, CustodianRequired: true} + } + + request.MigrationTransactionPlan.PlanCommitmentHash, _ = + computeMigrationTransactionPlanCommitmentHash( + request, + request.MigrationTransactionPlan, + ) + request.ArtifactApprovals = validArtifactApprovals(request) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + + return request +} + +func validArtifactApprovals(request RouteSubmitRequest) *ArtifactApprovalEnvelope { + payload := ArtifactApprovalPayload{ + ApprovalVersion: artifactApprovalVersion, + Route: request.Route, + ScriptTemplateID: request.Route, + DestinationCommitmentHash: request.DestinationCommitmentHash, + PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, + } + + approvals := []ArtifactRoleApproval{ + { + Role: ArtifactApprovalRoleDepositor, + Signature: mustArtifactApprovalSignature(testDepositorPrivateKey, payload), + }, + } + + if request.Route == TemplateQcV1 { + approvals = []ArtifactRoleApproval{ + { + Role: ArtifactApprovalRoleDepositor, + Signature: mustArtifactApprovalSignature(testDepositorPrivateKey, payload), + }, + { + Role: ArtifactApprovalRoleCustodian, + Signature: mustArtifactApprovalSignature(testCustodianPrivateKey, payload), + }, + } + } + + return &ArtifactApprovalEnvelope{ + Payload: payload, + Approvals: approvals, + } +} + +func validSignerApproval( + artifactApprovals *ArtifactApprovalEnvelope, +) *SignerApprovalCertificate { + if artifactApprovals == nil { + panic("artifact approvals are required") + } + + digest, err := artifactApprovalDigest(artifactApprovals.Payload) + if err != nil { + panic(err) + } + + endBlock := uint64(123456) + return &SignerApprovalCertificate{ + CertificateVersion: signerApprovalCertificateVersion, + SignatureAlgorithm: signerApprovalSignatureAlgorithm, + ApprovalDigest: "0x" + hex.EncodeToString(digest), + WalletPublicKey: testSignerUncompressedPublicKey, + SignerSetHash: "0x" + strings.Repeat("ab", 32), + Signature: "0x304402200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2002202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + ActiveMembers: []uint32{2, 1}, + InactiveMembers: []uint32{4, 3}, + EndBlock: &endBlock, + } +} + +func structuredSignerApprovalRequest(route TemplateID) RouteSubmitRequest { + request := baseRequest(route) + request.SignerApproval = validSignerApproval(request.ArtifactApprovals) + request.ArtifactSignatures = canonicalArtifactSignaturesWithSignerApproval( + request.Route, + request.ArtifactApprovals, + request.SignerApproval, + ) + + return request +} + +func canonicalArtifactApprovalRequest(route TemplateID) RouteSubmitRequest { + return baseRequest(route) +} + +const ( + mixedCaseCoverageStrategy = "0xaabbccddeeff00112233445566778899aabbccdd" + mixedCaseCoverageReserve = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + mixedCaseCoverageRevealer = "0xdecafbaddecafbaddecafbaddecafbaddecafbad" + mixedCaseCoverageVault = "0xbeadfeedbeadfeedbeadfeedbeadfeedbeadfeed" +) + +func canonicalMixedCaseCoverageArtifactApprovalRequest( + t *testing.T, + route TemplateID, +) RouteSubmitRequest { + t.Helper() + + request := canonicalArtifactApprovalRequest(route) + request.Strategy = mixedCaseCoverageStrategy + request.Reserve = mixedCaseCoverageReserve + request.MigrationDestination.Reserve = mixedCaseCoverageReserve + request.MigrationDestination.Revealer = mixedCaseCoverageRevealer + request.MigrationDestination.Vault = mixedCaseCoverageVault + request.MigrationDestination.MigrationExtraData = computeMigrationExtraData( + request.MigrationDestination.Revealer, + ) + + destinationCommitmentHash, err := computeDestinationCommitmentHash( + request.MigrationDestination, + ) + if err != nil { + t.Fatal(err) + } + request.MigrationDestination.DestinationCommitmentHash = destinationCommitmentHash + request.DestinationCommitmentHash = destinationCommitmentHash + + planCommitmentHash, err := computeMigrationTransactionPlanCommitmentHash( + request, + request.MigrationTransactionPlan, + ) + if err != nil { + t.Fatal(err) + } + request.MigrationTransactionPlan.PlanCommitmentHash = planCommitmentHash + request.ArtifactApprovals.Payload.DestinationCommitmentHash = destinationCommitmentHash + request.ArtifactApprovals.Payload.PlanCommitmentHash = planCommitmentHash + + return request +} + +func cloneRouteSubmitRequest( + t *testing.T, + request RouteSubmitRequest, +) RouteSubmitRequest { + t.Helper() + + cloned := RouteSubmitRequest{} + if err := strictUnmarshal(mustJSON(t, request), &cloned); err != nil { + t.Fatal(err) + } + + return cloned +} + +func artifactApprovalVariantFromRequest( + t *testing.T, + request RouteSubmitRequest, + transformHex func(string) string, +) RouteSubmitRequest { + t.Helper() + + variant := cloneRouteSubmitRequest(t, request) + variant.Strategy = transformHex(variant.Strategy) + variant.Reserve = transformHex(variant.Reserve) + variant.ActiveOutpoint.TxID = transformHex(variant.ActiveOutpoint.TxID) + if variant.ActiveOutpoint.ScriptHash != "" { + variant.ActiveOutpoint.ScriptHash = transformHex(variant.ActiveOutpoint.ScriptHash) + } + variant.DestinationCommitmentHash = transformHex(variant.DestinationCommitmentHash) + + if variant.MigrationDestination != nil { + variant.MigrationDestination.Reserve = transformHex(variant.MigrationDestination.Reserve) + variant.MigrationDestination.Revealer = transformHex(variant.MigrationDestination.Revealer) + variant.MigrationDestination.Vault = transformHex(variant.MigrationDestination.Vault) + variant.MigrationDestination.DepositScript = transformHex(variant.MigrationDestination.DepositScript) + variant.MigrationDestination.DepositScriptHash = transformHex(variant.MigrationDestination.DepositScriptHash) + variant.MigrationDestination.MigrationExtraData = transformHex(variant.MigrationDestination.MigrationExtraData) + variant.MigrationDestination.DestinationCommitmentHash = transformHex( + variant.MigrationDestination.DestinationCommitmentHash, + ) + } + + if variant.MigrationTransactionPlan != nil { + variant.MigrationTransactionPlan.PlanCommitmentHash = transformHex( + variant.MigrationTransactionPlan.PlanCommitmentHash, + ) + } + + for i := range variant.ArtifactSignatures { + variant.ArtifactSignatures[i] = transformHex(variant.ArtifactSignatures[i]) + } + + if variant.SignerApproval != nil { + variant.SignerApproval.ApprovalDigest = transformHex( + variant.SignerApproval.ApprovalDigest, + ) + variant.SignerApproval.WalletPublicKey = transformHex( + variant.SignerApproval.WalletPublicKey, + ) + variant.SignerApproval.SignerSetHash = transformHex( + variant.SignerApproval.SignerSetHash, + ) + variant.SignerApproval.Signature = transformHex( + variant.SignerApproval.Signature, + ) + if len(variant.SignerApproval.ActiveMembers) > 1 { + variant.SignerApproval.ActiveMembers = append( + []uint32{ + variant.SignerApproval.ActiveMembers[len(variant.SignerApproval.ActiveMembers)-1], + }, + variant.SignerApproval.ActiveMembers[:len(variant.SignerApproval.ActiveMembers)-1]..., + ) + } + if len(variant.SignerApproval.InactiveMembers) > 1 { + variant.SignerApproval.InactiveMembers = append( + []uint32{ + variant.SignerApproval.InactiveMembers[len(variant.SignerApproval.InactiveMembers)-1], + }, + variant.SignerApproval.InactiveMembers[:len(variant.SignerApproval.InactiveMembers)-1]..., + ) + } + } + + for pathID, artifact := range variant.Artifacts { + artifact.PSBTHash = transformHex(artifact.PSBTHash) + artifact.DestinationCommitmentHash = transformHex(artifact.DestinationCommitmentHash) + if artifact.TransactionHex != "" { + artifact.TransactionHex = transformHex(artifact.TransactionHex) + } + if artifact.TransactionID != "" { + artifact.TransactionID = transformHex(artifact.TransactionID) + } + variant.Artifacts[pathID] = artifact + } + + if variant.ArtifactApprovals != nil { + variant.ArtifactApprovals.Payload.DestinationCommitmentHash = transformHex( + variant.ArtifactApprovals.Payload.DestinationCommitmentHash, + ) + variant.ArtifactApprovals.Payload.PlanCommitmentHash = transformHex( + variant.ArtifactApprovals.Payload.PlanCommitmentHash, + ) + + reorderedApprovals := make( + []ArtifactRoleApproval, + len(variant.ArtifactApprovals.Approvals), + ) + for i := range variant.ArtifactApprovals.Approvals { + approval := variant.ArtifactApprovals.Approvals[len(variant.ArtifactApprovals.Approvals)-1-i] + reorderedApprovals[i] = ArtifactRoleApproval{ + Role: approval.Role, + Signature: transformHex(approval.Signature), + } + } + variant.ArtifactApprovals.Approvals = reorderedApprovals + } + + switch variant.Route { + case TemplateQcV1: + template := &QcV1Template{} + if err := strictUnmarshal(variant.ScriptTemplate, template); err != nil { + t.Fatal(err) + } + template.DepositorPublicKey = transformHex(template.DepositorPublicKey) + template.CustodianPublicKey = transformHex(template.CustodianPublicKey) + template.SignerPublicKey = transformHex(template.SignerPublicKey) + variant.ScriptTemplate = mustTemplate(template) + case TemplateSelfV1: + template := &SelfV1Template{} + if err := strictUnmarshal(variant.ScriptTemplate, template); err != nil { + t.Fatal(err) + } + template.DepositorPublicKey = transformHex(template.DepositorPublicKey) + template.SignerPublicKey = transformHex(template.SignerPublicKey) + variant.ScriptTemplate = mustTemplate(template) + default: + t.Fatalf("unsupported route %s", variant.Route) + } + + return variant +} + +func equivalentArtifactApprovalVariantFromRequest( + t *testing.T, + request RouteSubmitRequest, +) RouteSubmitRequest { + t.Helper() + return artifactApprovalVariantFromRequest(t, request, upperHexBody) +} + +func upperHexBody(value string) string { + if !strings.HasPrefix(value, "0x") { + return strings.ToUpper(value) + } + + return "0x" + strings.ToUpper(strings.TrimPrefix(value, "0x")) +} + +func mixedCaseHexBody(value string) string { + if !strings.HasPrefix(value, "0x") { + return value + } + + body := strings.ToLower(strings.TrimPrefix(value, "0x")) + lettersSeen := 0 + variant := strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'f' { + if lettersSeen%2 == 0 { + lettersSeen++ + return r - ('a' - 'A') + } + + lettersSeen++ + } + + return r + }, body) + + return "0x" + variant +} + +func mixedCaseArtifactApprovalVariantFromRequest( + t *testing.T, + request RouteSubmitRequest, +) RouteSubmitRequest { + t.Helper() + return artifactApprovalVariantFromRequest(t, request, mixedCaseHexBody) +} + +func validMigrationDestination() *MigrationDestinationReservation { + reservation := &MigrationDestinationReservation{ + ReservationID: "cmdr_12345678", + Reserve: "0x1111111111111111111111111111111111111111", + Epoch: 12, + Route: ReservationRouteMigration, + Revealer: "0x2222222222222222222222222222222222222222", + Vault: "0x3333333333333333333333333333333333333333", + Network: "regtest", + Status: ReservationStatusReserved, + DepositScript: "0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } + + reservation.DepositScriptHash, _ = computeDepositScriptHash(reservation.DepositScript) + reservation.MigrationExtraData = computeMigrationExtraData(reservation.Revealer) + reservation.DestinationCommitmentHash, _ = computeDestinationCommitmentHash(reservation) + + return reservation +} + +func validMigrationPlanQuote( + request RouteSubmitRequest, +) *MigrationDestinationPlanQuote { + quote := &MigrationDestinationPlanQuote{ + QuoteID: "cmdq_12345678", + QuoteVersion: migrationPlanQuoteVersion, + ReservationID: request.MigrationDestination.ReservationID, + Reserve: request.Reserve, + Epoch: request.Epoch, + Route: ReservationRouteMigration, + Revealer: request.MigrationDestination.Revealer, + Vault: request.MigrationDestination.Vault, + Network: request.MigrationDestination.Network, + DestinationCommitmentHash: request.DestinationCommitmentHash, + ActiveOutpointTxID: request.ActiveOutpoint.TxID, + ActiveOutpointVout: request.ActiveOutpoint.Vout, + PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, + MigrationTransactionPlan: normalizeMigrationTransactionPlan(request.MigrationTransactionPlan), + IdempotencyKey: "0x75a998ac6951c2776f3a85f6430fb41321c28c1113a71a52c754806c7a3de9c9", + ExpiresInSeconds: 900, + IssuedAt: "2099-03-09T00:00:00.000Z", + ExpiresAt: "2099-03-09T00:15:00.000Z", + Signature: MigrationDestinationPlanQuoteSignature{ + SignatureVersion: migrationPlanQuoteSignatureVersion, + Algorithm: migrationPlanQuoteSignatureAlgorithm, + KeyID: testMigrationPlanQuoteTrustRoot.KeyID, + }, + } + + signingHash, err := migrationPlanQuoteSigningHash(quote) + if err != nil { + panic(err) + } + quote.Signature.Signature = "0x" + hex.EncodeToString( + ed25519.Sign(testMigrationPlanQuotePrivateKey, signingHash), + ) + + return quote +} + +func requestWithValidMigrationPlanQuote(route TemplateID) RouteSubmitRequest { + request := baseRequest(route) + request.ActiveOutpoint.TxID = "0x" + strings.Repeat("aa", 32) + request.ActiveOutpoint.ScriptHash = "0x" + strings.Repeat("bb", 32) + request.MigrationTransactionPlan.PlanCommitmentHash, _ = + computeMigrationTransactionPlanCommitmentHash( + request, + request.MigrationTransactionPlan, + ) + request.ArtifactApprovals = validArtifactApprovals(request) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + request.MigrationPlanQuote = validMigrationPlanQuote(request) + + return request +} + +func TestServiceSubmitDeduplicatesByRouteRequestID(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_123", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + first, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + t.Fatal(err) + } + + second, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + t.Fatal(err) + } + + if first.RequestID == "" { + t.Fatal("expected durable request id") + } + if first.RequestID != second.RequestID { + t.Fatalf("expected dedupe on routeRequestId, got %s vs %s", first.RequestID, second.RequestID) + } +} + +func TestServiceSubmitRejectsRouteRequestIDDigestMismatch(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_duplicate_digest_mismatch", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + t.Fatal(err) + } + + input.Request.FacadeRequestID = "rf_different_payload" + + _, err = service.Submit(context.Background(), TemplateSelfV1, input) + if err == nil || !strings.Contains(err.Error(), "routeRequestId already exists with a different request payload") { + t.Fatalf("expected routeRequestId mismatch error, got %v", err) + } +} + +func TestServiceSubmitReturnsExistingJobWhileInitialEngineCallIsInFlight(t *testing.T) { + handle := newMemoryHandle() + engineStarted := make(chan struct{}) + releaseEngine := make(chan struct{}) + + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + select { + case <-engineStarted: + default: + close(engineStarted) + } + + <-releaseEngine + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_inflight", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + firstResultChan := make(chan StepResult, 1) + firstErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + firstErrChan <- err + return + } + + firstResultChan <- result + }() + + <-engineStarted + + secondResultChan := make(chan StepResult, 1) + secondErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + secondErrChan <- err + return + } + + secondResultChan <- result + }() + + var secondResult StepResult + select { + case err := <-secondErrChan: + t.Fatal(err) + case secondResult = <-secondResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected deduplicated submit to return while initial engine call is in flight") + } + + close(releaseEngine) + + var firstResult StepResult + select { + case err := <-firstErrChan: + t.Fatal(err) + case firstResult = <-firstResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected initial submit to finish after engine release") + } + + if firstResult.RequestID == "" { + t.Fatal("expected durable request id on initial submit") + } + if firstResult.RequestID != secondResult.RequestID { + t.Fatalf("expected in-flight dedupe to reuse request id, got %s vs %s", firstResult.RequestID, secondResult.RequestID) + } +} + +func TestServicePollReturnsNewerPersistedStateWhenItsTransitionBecomesStale(t *testing.T) { + handle := newMemoryHandle() + submitStarted := make(chan struct{}) + releaseSubmit := make(chan struct{}) + pollStarted := make(chan struct{}) + releasePoll := make(chan struct{}) + + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + select { + case <-submitStarted: + default: + close(submitStarted) + } + + <-releaseSubmit + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x090a", + TransactionHex: "0x0b0c", + }, nil + }, + poll: func(*Job) (*Transition, error) { + select { + case <-pollStarted: + default: + close(pollStarted) + } + + <-releasePoll + return &Transition{State: JobStatePending, Detail: "stale pending"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_poll_stale_pending", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + submitResultChan := make(chan StepResult, 1) + submitErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + submitErrChan <- err + return + } + + submitResultChan <- result + }() + + <-submitStarted + + storedJob, ok, err := service.store.GetByRouteRequest(TemplateSelfV1, input.RouteRequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected submitted job to exist while submit engine is in flight") + } + + pollResultChan := make(chan StepResult, 1) + pollErrChan := make(chan error, 1) + go func() { + result, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: input.RouteRequestID, + RequestID: storedJob.RequestID, + Stage: StageSignerCoordination, + Request: input.Request, + }) + if err != nil { + pollErrChan <- err + return + } + + pollResultChan <- result + }() + + <-pollStarted + close(releaseSubmit) + + var submitResult StepResult + select { + case err := <-submitErrChan: + t.Fatal(err) + case submitResult = <-submitResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected submit to finish after engine release") + } + + close(releasePoll) + + var pollResult StepResult + select { + case err := <-pollErrChan: + t.Fatal(err) + case pollResult = <-pollResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected poll to finish after release") + } + + if pollResult.Status != StepStatusReady { + t.Fatalf("expected stale poll to return latest READY state, got %#v", pollResult) + } + if pollResult.PSBTHash != submitResult.PSBTHash || pollResult.TransactionHex != submitResult.TransactionHex { + t.Fatalf("expected poll to return persisted READY payload, got submit=%#v poll=%#v", submitResult, pollResult) + } + + persistedJob, ok, err := service.store.GetByRequestID(storedJob.RequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected persisted job after stale poll") + } + if persistedJob.State != JobStateArtifactReady { + t.Fatalf("expected persisted READY state, got %s", persistedJob.State) + } +} + +func TestServiceSubmitReturnsNewerPersistedStateWhenItsTransitionBecomesStale(t *testing.T) { + handle := newMemoryHandle() + submitStarted := make(chan struct{}) + releaseSubmit := make(chan struct{}) + pollStarted := make(chan struct{}) + releasePoll := make(chan struct{}) + + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + select { + case <-submitStarted: + default: + close(submitStarted) + } + + <-releaseSubmit + return &Transition{State: JobStatePending, Detail: "stale pending"}, nil + }, + poll: func(*Job) (*Transition, error) { + select { + case <-pollStarted: + default: + close(pollStarted) + } + + <-releasePoll + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x090a", + TransactionHex: "0x0b0c", + }, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_submit_stale_pending", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + submitResultChan := make(chan StepResult, 1) + submitErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + submitErrChan <- err + return + } + + submitResultChan <- result + }() + + <-submitStarted + + storedJob, ok, err := service.store.GetByRouteRequest(TemplateSelfV1, input.RouteRequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected submitted job to exist while submit engine is in flight") + } + + pollResultChan := make(chan StepResult, 1) + pollErrChan := make(chan error, 1) + go func() { + result, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: input.RouteRequestID, + RequestID: storedJob.RequestID, + Stage: StageSignerCoordination, + Request: input.Request, + }) + if err != nil { + pollErrChan <- err + return + } + + pollResultChan <- result + }() + + <-pollStarted + close(releasePoll) + + var pollResult StepResult + select { + case err := <-pollErrChan: + t.Fatal(err) + case pollResult = <-pollResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected poll to finish after release") + } + + close(releaseSubmit) + + var submitResult StepResult + select { + case err := <-submitErrChan: + t.Fatal(err) + case submitResult = <-submitResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected submit to finish after release") + } + + if submitResult.Status != StepStatusReady { + t.Fatalf("expected stale submit to return latest READY state, got %#v", submitResult) + } + if submitResult.PSBTHash != pollResult.PSBTHash || submitResult.TransactionHex != pollResult.TransactionHex { + t.Fatalf("expected submit to return persisted READY payload, got submit=%#v poll=%#v", submitResult, pollResult) + } + + persistedJob, ok, err := service.store.GetByRequestID(storedJob.RequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected persisted job after stale submit") + } + if persistedJob.State != JobStateArtifactReady { + t.Fatalf("expected persisted READY state, got %s", persistedJob.State) + } +} + +func TestServicePollDoesNotOverwriteNewerPersistedStateWithJobNotFound(t *testing.T) { + handle := newMemoryHandle() + submitStarted := make(chan struct{}) + releaseSubmit := make(chan struct{}) + pollStarted := make(chan struct{}) + releasePoll := make(chan struct{}) + + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + select { + case <-submitStarted: + default: + close(submitStarted) + } + + <-releaseSubmit + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x0d0e", + TransactionHex: "0x0f10", + }, nil + }, + poll: func(*Job) (*Transition, error) { + select { + case <-pollStarted: + default: + close(pollStarted) + } + + <-releasePoll + return nil, errJobNotFound + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_poll_stale_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + submitResultChan := make(chan StepResult, 1) + submitErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + submitErrChan <- err + return + } + + submitResultChan <- result + }() + + <-submitStarted + + storedJob, ok, err := service.store.GetByRouteRequest(TemplateSelfV1, input.RouteRequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected submitted job to exist while submit engine is in flight") + } + + pollResultChan := make(chan StepResult, 1) + pollErrChan := make(chan error, 1) + go func() { + result, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: input.RouteRequestID, + RequestID: storedJob.RequestID, + Stage: StageSignerCoordination, + Request: input.Request, + }) + if err != nil { + pollErrChan <- err + return + } + + pollResultChan <- result + }() + + <-pollStarted + close(releaseSubmit) + + var submitResult StepResult + select { + case err := <-submitErrChan: + t.Fatal(err) + case submitResult = <-submitResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected submit to finish after engine release") + } + + close(releasePoll) + + var pollResult StepResult + select { + case err := <-pollErrChan: + t.Fatal(err) + case pollResult = <-pollResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected poll to finish after release") + } + + if pollResult.Status != StepStatusReady { + t.Fatalf("expected stale job-not-found poll to return latest READY state, got %#v", pollResult) + } + if pollResult.PSBTHash != submitResult.PSBTHash || pollResult.TransactionHex != submitResult.TransactionHex { + t.Fatalf("expected poll to return persisted READY payload, got submit=%#v poll=%#v", submitResult, pollResult) + } +} + +func TestServicePollCanTransitionToReady(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x090a", + TransactionHex: "0x0b0c", + }, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_ready", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } + + pollResult, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "ors_ready", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } + + if pollResult.Status != StepStatusReady { + t.Fatalf("expected READY, got %s", pollResult.Status) + } + if pollResult.PSBTHash != "0x090a" || pollResult.TransactionHex != "0x0b0c" { + t.Fatalf("unexpected ready payload: %#v", pollResult) + } +} + +func TestServiceTimestampsAdvanceAcrossTransitions(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x090a", + TransactionHex: "0x0b0c", + }, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_timestamps", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } + + submittedJob, ok, err := service.store.GetByRequestID(submitResult.RequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected submitted job") + } + + time.Sleep(5 * time.Millisecond) + + _, err = service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "ors_timestamps", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } + + polledJob, ok, err := service.store.GetByRequestID(submitResult.RequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected polled job") + } + + if submittedJob.CreatedAt == polledJob.UpdatedAt { + t.Fatalf("expected updated timestamp to advance, got created=%s updated=%s", submittedJob.CreatedAt, polledJob.UpdatedAt) + } + if polledJob.CompletedAt == "" { + t.Fatal("expected completed timestamp to be populated") + } +} + +func TestServicePollMapsJobNotFoundToFailed(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return nil, errJobNotFound + }, + }) + if err != nil { + t.Fatal(err) + } + + submitResult, err := service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } + + pollResult, err := service.Poll(context.Background(), TemplateQcV1, SignerPollInput{ + RouteRequestID: "orq_missing", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } + + if pollResult.Status != StepStatusFailed || pollResult.Reason != ReasonJobNotFound { + t.Fatalf("unexpected failed result: %#v", pollResult) + } +} + +func TestMigrationDestinationMatchesKnownVector(t *testing.T) { + reservation := validMigrationDestination() + + if reservation.DepositScriptHash != "0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3" { + t.Fatalf("unexpected depositScriptHash: %s", reservation.DepositScriptHash) + } + if reservation.MigrationExtraData != "0x41435f4d49475241544556312222222222222222222222222222222222222222" { + t.Fatalf("unexpected migrationExtraData: %s", reservation.MigrationExtraData) + } + if reservation.DestinationCommitmentHash != "0x3efc50372759413e0f1900a2340fbb947648c524e5ec3cb4cf8887ea2d7df474" { + t.Fatalf("unexpected destinationCommitmentHash: %s", reservation.DestinationCommitmentHash) + } +} + +func TestServiceRejectsMismatchedMigrationDestinationArtifact(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateSelfV1) + request.MigrationDestination.DepositScriptHash = "0xdeadbeef" + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_bad_reservation", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), "depositScriptHash does not match depositScript") { + t.Fatalf("expected depositScriptHash mismatch, got %v", err) + } +} + +func TestServiceRejectsInvalidMigrationDestinationVariants(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + testCases := []struct { + name string + mutate func(request *RouteSubmitRequest) + expectErr string + }{ + { + name: "missing reservation artifact", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination = nil + }, + expectErr: "request.migrationDestination is required", + }, + { + name: "wrong reservation route", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.Route = "COOPERATIVE" + }, + expectErr: "request.migrationDestination.route must be MIGRATION", + }, + { + name: "retired reservation status", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.Status = ReservationStatusRetired + }, + expectErr: "request.migrationDestination.status must be RESERVED or COMMITTED_TO_EPOCH", + }, + { + name: "epoch mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.Epoch = 13 + }, + expectErr: "request.migrationDestination.epoch does not match request.epoch", + }, + { + name: "reserve mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.Reserve = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + expectErr: "request.migrationDestination.reserve does not match request.reserve", + }, + { + name: "request commitment mismatch", + mutate: func(request *RouteSubmitRequest) { + request.DestinationCommitmentHash = "0xdeadbeef" + }, + expectErr: "request.migrationDestination.destinationCommitmentHash does not match request.destinationCommitmentHash", + }, + { + name: "migration extraData mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.MigrationExtraData = "0xdeadbeef" + }, + expectErr: "request.migrationDestination.migrationExtraData does not match migration revealer encoding", + }, + { + name: "canonical commitment mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.DestinationCommitmentHash = "0xdeadbeef" + request.DestinationCommitmentHash = "0xdeadbeef" + }, + expectErr: "request.migrationDestination.destinationCommitmentHash does not match canonical reservation artifact", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + request := baseRequest(TemplateSelfV1) + testCase.mutate(&request) + + _, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_invalid_variant_" + strings.ReplaceAll(testCase.name, " ", "_"), + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), testCase.expectErr) { + t.Fatalf("expected %q, got %v", testCase.expectErr, err) + } + }) + } +} + +func TestServiceRejectsInvalidMigrationTransactionPlanVariants(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + testCases := []struct { + name string + mutate func(request *RouteSubmitRequest) + expectErr string + }{ + { + name: "missing transaction plan", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan = nil + }, + expectErr: "request.migrationTransactionPlan is required", + }, + { + name: "missing plan version", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.PlanVersion = 0 + }, + expectErr: "request.migrationTransactionPlan.planVersion must equal 1", + }, + { + name: "wrong commitment hash", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.PlanCommitmentHash = "" + }, + expectErr: "request.migrationTransactionPlan.planCommitmentHash must be a 0x-prefixed even-length hex string", + }, + { + name: "zero input value", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.InputValueSats = 0 + }, + expectErr: "request.migrationTransactionPlan.inputValueSats must be greater than zero", + }, + { + name: "zero destination value", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.DestinationValueSats = 0 + }, + expectErr: "request.migrationTransactionPlan.destinationValueSats must be greater than zero", + }, + { + name: "zero fee", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.FeeSats = 0 + request.MigrationTransactionPlan.DestinationValueSats = + request.MigrationTransactionPlan.InputValueSats - + request.MigrationTransactionPlan.AnchorValueSats + }, + expectErr: "request.migrationTransactionPlan.feeSats must be greater than zero", + }, + { + name: "wrong anchor value", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.AnchorValueSats = 331 + }, + expectErr: "request.migrationTransactionPlan.anchorValueSats must equal the canonical 330 sat anchor", + }, + { + name: "wrong input sequence", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.InputSequence = 0xFFFFFFFF + }, + expectErr: "request.migrationTransactionPlan.inputSequence must equal 0xFFFFFFFD", + }, + { + name: "locktime mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.LockTime = uint32(request.MaturityHeight + 1) + }, + expectErr: "request.migrationTransactionPlan.lockTime must match request.maturityHeight", + }, + { + name: "maturity height exceeds uint32", + mutate: func(request *RouteSubmitRequest) { + request.MaturityHeight = 0x1_0000_0000 + }, + expectErr: "request.maturityHeight must fit in uint32", + }, + { + name: "insufficient input for destination", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.InputValueSats = request.MigrationTransactionPlan.DestinationValueSats - 1 + }, + expectErr: "request.migrationTransactionPlan.inputValueSats must cover destinationValueSats", + }, + { + name: "insufficient input for anchor", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.InputValueSats = request.MigrationTransactionPlan.DestinationValueSats + canonicalAnchorValueSats - 1 + }, + expectErr: "request.migrationTransactionPlan.inputValueSats must cover anchorValueSats", + }, + { + name: "tampered commitment hash", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.PlanCommitmentHash = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + expectErr: "request.migrationTransactionPlan.planCommitmentHash does not match canonical migration transaction plan", + }, + { + name: "accounting mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.FeeSats++ + }, + expectErr: "request.migrationTransactionPlan values must satisfy inputValueSats = destinationValueSats + anchorValueSats + feeSats", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + request := baseRequest(TemplateSelfV1) + testCase.mutate(&request) + + _, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_invalid_plan_" + strings.ReplaceAll(testCase.name, " ", "_"), + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), testCase.expectErr) { + t.Fatalf("expected %q, got %v", testCase.expectErr, err) + } + }) + } +} + +func TestServiceRejectsMigrationTransactionPlanBoundToDifferentDestinationCommitment(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateSelfV1) + + mutatedDestination := validMigrationDestination() + mutatedDestination.Revealer = "0x4444444444444444444444444444444444444444" + mutatedDestination.MigrationExtraData = computeMigrationExtraData(mutatedDestination.Revealer) + mutatedDestination.DestinationCommitmentHash, err = computeDestinationCommitmentHash(mutatedDestination) + if err != nil { + t.Fatal(err) + } + + request.DestinationCommitmentHash = mutatedDestination.DestinationCommitmentHash + request.MigrationDestination = mutatedDestination + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_invalid_plan_destination_binding", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), "request.migrationTransactionPlan.planCommitmentHash does not match canonical migration transaction plan") { + t.Fatalf("expected plan binding error, got %v", err) + } +} + +func TestServiceAcceptsStructuredSignerApprovalWithCanonicalLegacySignatures(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(RouteSubmitRequest) error { + return nil + })), + ) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateQcV1) + request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ + request.ArtifactApprovals.Approvals[0], + request.ArtifactApprovals.Approvals[1], + } + request.ArtifactSignatures = canonicalArtifactSignaturesWithSignerApproval( + request.Route, + request.ArtifactApprovals, + request.SignerApproval, + ) + + result, err := service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_artifact_approvals", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != StepStatusPending { + t.Fatalf("expected PENDING, got %#v", result) + } + + job, ok, err := service.store.GetByRouteRequest(TemplateQcV1, "orq_artifact_approvals") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected stored job") + } + if job.Request.ArtifactApprovals == nil { + t.Fatal("expected stored artifact approvals") + } +} + +func TestServiceAcceptsStructuredSignerApprovalWhenVerifierConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + if request.SignerApproval == nil { + t.Fatal("expected signer approval") + } + return nil + })), + ) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateQcV1) + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_structured_signer_approval", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + job, ok, err := service.store.GetByRouteRequest( + TemplateQcV1, + "orq_structured_signer_approval", + ) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected stored job") + } + if job.Request.SignerApproval == nil { + t.Fatal("expected stored signer approval") + } + if !reflect.DeepEqual( + job.Request.SignerApproval.ActiveMembers, + []uint32{1, 2}, + ) { + t.Fatalf( + "unexpected active members: %#v", + job.Request.SignerApproval.ActiveMembers, + ) + } + if !reflect.DeepEqual( + job.Request.SignerApproval.InactiveMembers, + []uint32{3, 4}, + ) { + t.Fatalf( + "unexpected inactive members: %#v", + job.Request.SignerApproval.InactiveMembers, + ) + } +} + +func TestServiceRejectsStructuredSignerApprovalWithoutVerifier(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_structured_signer_approval_unsupported", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval cannot be verified by this signer deployment", + ) { + t.Fatalf("expected unsupported signer approval error, got %v", err) + } +} + +func TestServiceRejectsMissingSignerApprovalWhenVerifierConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(RouteSubmitRequest) error { + return nil + })), + ) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateSelfV1) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_legacy_signer_approval_path", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval is required when the signer approval verifier is configured", + ) { + t.Fatalf("expected missing signer approval error, got %v", err) + } +} + +func TestServiceRejectsStructuredSignerApprovalWithMismatchedApprovalDigest(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(RouteSubmitRequest) error { + return nil + })), + ) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + request.SignerApproval.ApprovalDigest = "0x" + strings.Repeat("11", 32) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_structured_signer_approval_bad_digest", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval.approvalDigest must match the canonical artifactApprovals payload digest", + ) { + t.Fatalf("expected signer approval digest mismatch error, got %v", err) + } +} +func TestServiceRejectsStructuredSignerApprovalWithLegacySignerRole(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(RouteSubmitRequest) error { + return nil + })), + ) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + request.ArtifactApprovals.Approvals = append( + request.ArtifactApprovals.Approvals, + ArtifactRoleApproval{ + Role: ArtifactApprovalRole("S"), + Signature: "0x5151", + }, + ) + request.ArtifactSignatures = append( + request.ArtifactSignatures[:len(request.ArtifactSignatures)-1], + "0x5151", + request.ArtifactSignatures[len(request.ArtifactSignatures)-1], + ) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_structured_signer_approval_legacy_role", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.artifactApprovals.approvals[1].role is not allowed for self_v1", + ) { + t.Fatalf("expected structured signer-role rejection, got %v", err) + } +} + +func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + testCases := []struct { + name string + route TemplateID + mutate func(request *RouteSubmitRequest) + expectErr string + }{ + { + name: "missing qc custodian approval", + route: TemplateQcV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ + request.ArtifactApprovals.Approvals[0], + } + request.ArtifactSignatures = []string{ + request.ArtifactSignatures[0], + } + }, + expectErr: "request.artifactApprovals.approvals must include role C for qc_v1", + }, + { + name: "self route rejects custodian approval role", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ + request.ArtifactApprovals.Approvals[0], + { + Role: ArtifactApprovalRoleCustodian, + Signature: mustArtifactApprovalSignature( + testCustodianPrivateKey, + request.ArtifactApprovals.Payload, + ), + }, + } + }, + expectErr: "request.artifactApprovals.approvals[1].role is not allowed for self_v1", + }, + { + name: "plan commitment mismatch", + route: TemplateQcV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals.Payload.PlanCommitmentHash = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + expectErr: "request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash", + }, + { + name: "artifact approvals required", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals = nil + }, + expectErr: "request.artifactApprovals is required", + }, + { + name: "legacy signature mismatch", + route: TemplateQcV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactSignatures = []string{ + request.ArtifactSignatures[1], + request.ArtifactSignatures[0], + } + }, + expectErr: "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals", + }, + { + name: "legacy signatures remain required when approvals are present", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactSignatures = nil + }, + expectErr: "request.artifactSignatures must not be empty", + }, + { + name: "depositor signature does not verify", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + setArtifactApprovalSignature( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + mustArtifactApprovalSignature( + testCustodianPrivateKey, + request.ArtifactApprovals.Payload, + ), + ) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + }, + expectErr: "request.artifactApprovals.approvals[0].signature does not verify against the required public key", + }, + { + name: "custodian signature does not verify", + route: TemplateQcV1, + mutate: func(request *RouteSubmitRequest) { + setArtifactApprovalSignature( + request.ArtifactApprovals, + ArtifactApprovalRoleCustodian, + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + ), + ) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + }, + expectErr: "request.artifactApprovals.approvals[1].signature does not verify against the required public key", + }, + { + name: "depositor signature must be low-S", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + setArtifactApprovalSignature( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + mustHighSCompactVariantSignature( + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + ), + ), + ) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + }, + expectErr: "request.artifactApprovals.approvals[0].signature must be a low-S secp256k1 signature", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + request := baseRequest(testCase.route) + testCase.mutate(&request) + + _, err := service.Submit(context.Background(), testCase.route, SignerSubmitInput{ + RouteRequestID: "ors_invalid_artifact_approval_" + strings.ReplaceAll(testCase.name, " ", "_"), + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), testCase.expectErr) { + t.Fatalf("expected %q, got %v", testCase.expectErr, err) + } + }) + } +} + +func TestRequestDigestNormalizesEquivalentArtifactApprovalVariants(t *testing.T) { + canonicalDigest, err := requestDigest(canonicalArtifactApprovalRequest(TemplateQcV1), validationOptions{}) + if err != nil { + t.Fatal(err) + } + + variantDigest, err := requestDigest( + equivalentArtifactApprovalVariantFromRequest( + t, + canonicalArtifactApprovalRequest(TemplateQcV1), + ), + validationOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + if canonicalDigest != variantDigest { + t.Fatalf("expected matching request digest, got %s vs %s", canonicalDigest, variantDigest) + } +} + +func TestRequestDigestNormalizesEquivalentStructuredSignerApprovalVariants(t *testing.T) { + canonicalDigest, err := requestDigest(structuredSignerApprovalRequest(TemplateQcV1), validationOptions{}) + if err != nil { + t.Fatal(err) + } + + variantDigest, err := requestDigest( + equivalentArtifactApprovalVariantFromRequest( + t, + structuredSignerApprovalRequest(TemplateQcV1), + ), + validationOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + if canonicalDigest != variantDigest { + t.Fatalf( + "expected matching structured request digest, got %s vs %s", + canonicalDigest, + variantDigest, + ) + } +} + +func TestRequestDigestDoesNotEscapeHTMLSensitiveCharacters(t *testing.T) { + request := canonicalArtifactApprovalRequest(TemplateSelfV1) + request.FacadeRequestID = "rf_&sink" + request.IdempotencyKey = "idem_>bridge" + + normalizedRequest, err := normalizeRouteSubmitRequest(request, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + payload, err := canonicaljson.Marshal(normalizedRequest) + if err != nil { + t.Fatal(err) + } + + if !bytes.Contains(payload, []byte(`"facadeRequestId":"rf_&sink"`)) { + t.Fatalf("expected raw HTML-sensitive characters in payload, got %s", payload) + } + if bytes.Contains(payload, []byte(`\u003c`)) || + bytes.Contains(payload, []byte(`\u003e`)) || + bytes.Contains(payload, []byte(`\u0026`)) { + t.Fatalf("expected unescaped HTML-sensitive characters in payload, got %s", payload) + } + + digestFromRawRequest, err := requestDigest(request, validationOptions{}) + if err != nil { + t.Fatal(err) + } + digestFromNormalizedRequest, err := requestDigestFromNormalized(normalizedRequest) + if err != nil { + t.Fatal(err) + } + if digestFromRawRequest != digestFromNormalizedRequest { + t.Fatalf( + "expected matching digests, got %s vs %s", + digestFromRawRequest, + digestFromNormalizedRequest, + ) + } +} + +func TestDestinationCommitmentHashDoesNotEscapeHTMLSensitiveCharacters(t *testing.T) { + destination := validMigrationDestination() + destination.Network = "regtest&sink" + + payload, err := canonicaljson.Marshal(destinationCommitmentPayload{ + Reserve: normalizeLowerHex(destination.Reserve), + Epoch: destination.Epoch, + Route: string(destination.Route), + Revealer: normalizeLowerHex(destination.Revealer), + Vault: normalizeLowerHex(destination.Vault), + Network: strings.TrimSpace(destination.Network), + DepositScriptHash: normalizeLowerHex(destination.DepositScriptHash), + MigrationExtraData: normalizeLowerHex(destination.MigrationExtraData), + }) + if err != nil { + t.Fatal(err) + } + + if !bytes.Contains(payload, []byte(`"network":"regtest&sink"`)) { + t.Fatalf("expected raw HTML-sensitive characters in payload, got %s", payload) + } + if bytes.Contains(payload, []byte(`\u003c`)) || + bytes.Contains(payload, []byte(`\u003e`)) || + bytes.Contains(payload, []byte(`\u0026`)) { + t.Fatalf("expected unescaped HTML-sensitive characters in payload, got %s", payload) + } + + hash, err := computeDestinationCommitmentHash(destination) + if err != nil { + t.Fatal(err) + } + if hash == "" { + t.Fatal("expected destination commitment hash") + } +} + +func TestMigrationPlanQuoteSigningVectorsMatchFixture(t *testing.T) { + vectors := loadMigrationPlanQuoteSigningVectors(t) + if vectors.Version != 1 { + t.Fatalf("unexpected vector version: %d", vectors.Version) + } + if vectors.Scope != "migration_plan_quote_signing_contract_v1" { + t.Fatalf("unexpected vector scope: %s", vectors.Scope) + } + + block, _ := pem.Decode([]byte(vectors.TrustRoot.PublicKeyPEM)) + if block == nil { + t.Fatal("expected migration plan quote fixture to contain a PEM public key") + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatal(err) + } + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok { + t.Fatalf("expected Ed25519 public key, got %T", parsedPublicKey) + } + + for name, vector := range vectors.Vectors { + t.Run(name, func(t *testing.T) { + payload, err := migrationPlanQuoteSigningPayloadBytes(&vector.UnsignedQuote) + if err != nil { + t.Fatal(err) + } + if string(payload) != vector.ExpectedPayload { + t.Fatalf("unexpected signing payload: %s", payload) + } + + preimage, err := migrationPlanQuoteSigningPreimage(&vector.UnsignedQuote) + if err != nil { + t.Fatal(err) + } + if string(preimage) != vector.ExpectedPreimage { + t.Fatalf("unexpected signing preimage: %s", preimage) + } + + signingHash, err := migrationPlanQuoteSigningHash(&vector.UnsignedQuote) + if err != nil { + t.Fatal(err) + } + if "0x"+hex.EncodeToString(signingHash) != vector.ExpectedHash { + t.Fatalf("unexpected signing hash: 0x%s", hex.EncodeToString(signingHash)) + } + + rawSignature, err := hex.DecodeString(strings.TrimPrefix(vector.ExpectedSignature, "0x")) + if err != nil { + t.Fatal(err) + } + if !ed25519.Verify(publicKey, signingHash, rawSignature) { + t.Fatal("expected fixture signature to verify against the fixture trust root") + } + }) + } +} + +func TestServiceRequiresMigrationPlanQuoteWhenTrustRootsConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_required", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err == nil || !strings.Contains( + err.Error(), + "request.migrationPlanQuote is required when migrationPlanQuoteTrustRoots are configured", + ) { + t.Fatalf("expected missing quote error, got %v", err) + } +} + +func TestServiceAcceptsValidMigrationPlanQuoteWhenTrustRootsConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + + request := requestWithValidMigrationPlanQuote(TemplateSelfV1) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_valid", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestServiceRejectsExpiredMigrationPlanQuoteOnSubmit(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + service.now = func() time.Time { + return time.Date(2099, time.March, 9, 0, 16, 0, 0, time.UTC) + } + + request := requestWithValidMigrationPlanQuote(TemplateSelfV1) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_expired", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), "request.migrationPlanQuote is expired") { + t.Fatalf("expected expired quote error, got %v", err) + } +} + +func TestServicePollAcceptsStoredMigrationPlanQuoteAfterQuoteExpiry(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + service.now = func() time.Time { + return time.Date(2099, time.March, 9, 0, 10, 0, 0, time.UTC) + } + + request := requestWithValidMigrationPlanQuote(TemplateSelfV1) + + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_poll", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + service.now = func() time.Time { + return time.Date(2099, time.March, 9, 0, 16, 0, 0, time.UTC) + } + + pollResult, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "ors_quote_poll", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + if pollResult.Status != StepStatusPending { + t.Fatalf("expected pending poll result, got %#v", pollResult) + } +} + +func TestServicePollRemainsValidAfterMigrationQuoteTrustRootConfigDrift(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + service.now = func() time.Time { + return time.Date(2099, time.March, 9, 0, 10, 0, 0, time.UTC) + } + + request := requestWithValidMigrationPlanQuote(TemplateSelfV1) + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_config_drift", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + service.migrationPlanQuoteTrustRoots = nil + + pollResult, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "ors_quote_config_drift", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + if pollResult.Status != StepStatusPending { + t.Fatalf("expected pending poll result, got %#v", pollResult) + } +} + +func TestParseMigrationPlanQuoteTrustRootRejectsInvalidPEM(t *testing.T) { + _, err := parseMigrationPlanQuoteTrustRoot("trustRoot", MigrationPlanQuoteTrustRoot{ + PublicKeyPEM: "not a PEM value", + }) + if err == nil || !strings.Contains(err.Error(), "trustRoot.publicKeyPem must be a PEM-encoded public key") { + t.Fatalf("expected invalid PEM error, got: %v", err) + } +} + +func TestParseMigrationPlanQuoteTrustRootRejectsNonEd25519Key(t *testing.T) { + secpKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + publicKeyDER, err := x509.MarshalPKIXPublicKey(&secpKey.PublicKey) + if err != nil { + t.Fatal(err) + } + + _, err = parseMigrationPlanQuoteTrustRoot("trustRoot", MigrationPlanQuoteTrustRoot{ + PublicKeyPEM: string( + pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDER, + }), + ), + }) + if err == nil || !strings.Contains(err.Error(), "trustRoot.publicKeyPem must be a PEM-encoded Ed25519 public key") { + t.Fatalf("expected non-ed25519 key error, got: %v", err) + } +} + +func TestServiceAcceptsSelfV1WithMatchingDepositorTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateSelfV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_trust_root_match", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } +} + +func TestServiceRejectsSelfV1WithoutMatchingDepositorTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateSelfV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateSelfV1) + request.ScriptTemplate = mustTemplate(SelfV1Template{ + Template: TemplateSelfV1, + DepositorPublicKey: testSignerPublicKey, + SignerPublicKey: testSignerPublicKey, + Delta2: 4320, + }) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_trust_root_mismatch", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.depositorPublicKey must match the configured depositorTrustRoots publicKey for self_v1", + ) { + t.Fatalf("expected self_v1 depositor trust-root mismatch, got %v", err) + } +} + +func TestServiceRejectsSelfV1WithoutConfiguredDepositorTrustRootMatch(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + { + Route: TemplateSelfV1, + Reserve: "0x9999999999999999999999999999999999999999", + Network: "regtest", + PublicKey: testDepositorPublicKey, + }, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_trust_root_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.depositorPublicKey requires a matching configured depositorTrustRoots entry for self_v1", + ) { + t.Fatalf("expected missing self_v1 depositor trust-root error, got %v", err) + } +} + +func TestServiceAcceptsQcV1WithMatchingCustodianTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithCustodianTrustRoots([]CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_trust_root_match", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } +} + +func TestServiceAcceptsQcV1WithMatchingDepositorAndCustodianTrustRoots(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }), + WithCustodianTrustRoots([]CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_depositor_and_custodian_trust_root_match", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } +} + +func TestServiceRejectsQcV1WithoutMatchingCustodianTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithCustodianTrustRoots([]CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateQcV1) + request.ScriptTemplate = mustTemplate(QcV1Template{ + Template: TemplateQcV1, + DepositorPublicKey: testDepositorPublicKey, + CustodianPublicKey: testSignerPublicKey, + SignerPublicKey: testSignerPublicKey, + Beta: 144, + Delta2: 4320, + }) + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_trust_root_mismatch", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.custodianPublicKey must match the configured custodianTrustRoots publicKey for qc_v1", + ) { + t.Fatalf("expected qc_v1 custodian trust-root mismatch, got %v", err) + } +} + +func TestServiceRejectsQcV1WithoutMatchingDepositorTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateQcV1) + request.ScriptTemplate = mustTemplate(QcV1Template{ + Template: TemplateQcV1, + DepositorPublicKey: testSignerPublicKey, + CustodianPublicKey: testCustodianPublicKey, + SignerPublicKey: testSignerPublicKey, + Beta: 144, + Delta2: 4320, + }) + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_depositor_trust_root_mismatch", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.depositorPublicKey must match the configured depositorTrustRoots publicKey for qc_v1", + ) { + t.Fatalf("expected qc_v1 depositor trust-root mismatch, got %v", err) + } +} + +func TestServiceRejectsQcV1WithoutConfiguredDepositorTrustRootMatch(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + { + Route: TemplateQcV1, + Reserve: "0x9999999999999999999999999999999999999999", + Network: "regtest", + PublicKey: testDepositorPublicKey, + }, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_depositor_trust_root_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.depositorPublicKey requires a matching configured depositorTrustRoots entry for qc_v1", + ) { + t.Fatalf("expected missing qc_v1 depositor trust-root error, got %v", err) + } +} + +func TestServiceRejectsQcV1WithoutConfiguredCustodianTrustRootMatch(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithCustodianTrustRoots([]CustodianTrustRoot{ + { + Route: TemplateQcV1, + Reserve: "0x9999999999999999999999999999999999999999", + Network: "regtest", + PublicKey: testCustodianPublicKey, + }, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_trust_root_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.custodianPublicKey requires a matching configured custodianTrustRoots entry for qc_v1", + ) { + t.Fatalf("expected missing qc_v1 custodian trust-root error, got %v", err) + } +} + +func TestNewServiceRejectsDuplicateDepositorTrustRootScope(t *testing.T) { + handle := newMemoryHandle() + + _, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateSelfV1), + testDepositorTrustRoot(TemplateSelfV1), + }), + ) + if err == nil || !strings.Contains( + err.Error(), + "duplicates depositorTrustRoots[0]", + ) { + t.Fatalf("expected duplicate depositor trust-root error, got %v", err) + } +} + +func TestNewServiceRejectsInvalidCustodianTrustRootPublicKey(t *testing.T) { + handle := newMemoryHandle() + + _, err := NewService( + handle, + &scriptedEngine{}, + WithCustodianTrustRoots([]CustodianTrustRoot{ + { + Route: TemplateQcV1, + Reserve: validMigrationDestination().Reserve, + Network: validMigrationDestination().Network, + PublicKey: "0x1234", + }, + }), + ) + if err == nil || !strings.Contains( + err.Error(), + "custodianTrustRoots[0].publicKey must be a compressed secp256k1 public key", + ) { + t.Fatalf("expected invalid custodian trust-root public key error, got %v", err) + } +} + +func TestServiceAcceptsMixedCaseDepositorTrustRootConfig(t *testing.T) { + handle := newMemoryHandle() + migrationDestination := validMigrationDestination() + + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + { + Route: TemplateSelfV1, + Reserve: mixedCaseHexBody(migrationDestination.Reserve), + Network: strings.ToUpper(migrationDestination.Network), + PublicKey: mixedCaseHexBody(testDepositorPublicKey), + }, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_trust_root_mixed_case_config", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } +} + +func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + submitRequest := equivalentArtifactApprovalVariantFromRequest( + t, + canonicalArtifactApprovalRequest(TemplateQcV1), + ) + submitResult, err := service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_equivalent_digest", + Stage: StageSignerCoordination, + Request: submitRequest, + }) + if err != nil { + t.Fatal(err) + } + + pollResult, err := service.Poll(context.Background(), TemplateQcV1, SignerPollInput{ + RouteRequestID: "orq_equivalent_digest", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: canonicalArtifactApprovalRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } + + if pollResult.Status != StepStatusPending { + t.Fatalf("expected PENDING, got %#v", pollResult) + } +} + +func TestServiceStoresNormalizedArtifactApprovalRequest(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := equivalentArtifactApprovalVariantFromRequest( + t, + canonicalArtifactApprovalRequest(TemplateQcV1), + ) + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_normalized_store", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + job, ok, err := service.store.GetByRouteRequest(TemplateQcV1, "orq_normalized_store") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected stored job") + } + + expected := canonicalArtifactApprovalRequest(TemplateQcV1) + if !reflect.DeepEqual(job.Request, expected) { + t.Fatalf("expected normalized request %#v, got %#v", expected, job.Request) + } +} + +func TestRequestDigestRejectsArtifactApprovalsWithoutMigrationTransactionPlan(t *testing.T) { + request := canonicalArtifactApprovalRequest(TemplateSelfV1) + request.MigrationTransactionPlan = nil + + _, err := requestDigest(request, validationOptions{}) + if err == nil || !strings.Contains( + err.Error(), + "request.migrationTransactionPlan is required when request.artifactApprovals is present", + ) { + t.Fatalf("expected missing plan error, got %v", err) + } +} + +func TestArtifactApprovalDigestMatchesPhase1Contract(t *testing.T) { + expectedDigests := map[TemplateID]string{ + TemplateQcV1: "0x4e1c72624e85c41d8d8a050d75704dc881ec6cd2dcfe1d240052887feef87ad8", + TemplateSelfV1: "0x960d7082d6eac550d7647d8fbeb90781e6cbd001b4d433e6635aa447dd937e79", + } + + for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { + t.Run(string(route), func(t *testing.T) { + request := canonicalArtifactApprovalRequest(route) + + digest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + t.Fatal(err) + } + + actualDigest := "0x" + hex.EncodeToString(digest) + if actualDigest != expectedDigests[route] { + t.Fatalf("expected digest %s, got %s", expectedDigests[route], actualDigest) + } + }) + } +} + +func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) { + for _, vectorKey := range []string{"qc_v1", "self_v1", "self_v1_presign"} { + t.Run(vectorKey, func(t *testing.T) { + request, expectedApprovalDigest, expectedDigest := loadApprovalContractVector(t, vectorKey) + + digestBytes, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + t.Fatal(err) + } + if actualApprovalDigest := "0x" + hex.EncodeToString(digestBytes); actualApprovalDigest != expectedApprovalDigest { + t.Fatalf( + "expected approval digest %s, got %s", + expectedApprovalDigest, + actualApprovalDigest, + ) + } + + digest, err := requestDigest(request, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + if digest != expectedDigest { + t.Fatalf("expected digest %s, got %s", expectedDigest, digest) + } + }) + } +} + +func TestApprovalContractVectorsNormalizeEquivalentVariants(t *testing.T) { + for _, vectorKey := range []string{"qc_v1", "self_v1", "self_v1_presign"} { + t.Run(vectorKey, func(t *testing.T) { + canonicalRequest, _, expectedDigest := loadApprovalContractVector(t, vectorKey) + + normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + variantRequest := equivalentArtifactApprovalVariantFromRequest( + t, + canonicalRequest, + ) + normalizedVariant, err := normalizeRouteSubmitRequest(variantRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(normalizedVariant, normalizedCanonical) { + t.Fatalf( + "expected normalized variant %#v, got %#v", + normalizedCanonical, + normalizedVariant, + ) + } + + digest, err := requestDigest(variantRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + if digest != expectedDigest { + t.Fatalf("expected digest %s, got %s", expectedDigest, digest) + } + }) + } +} + +func TestRequestDigestDistinguishesSelfV1PresignFromReconstruct(t *testing.T) { + reconstructRequest := structuredSignerApprovalRequest(TemplateSelfV1) + reconstructRequest.RequestType = RequestTypeReconstruct + + presignRequest := cloneRouteSubmitRequest(t, reconstructRequest) + presignRequest.RequestType = RequestTypePresignSelfV1 + + reconstructDigest, err := requestDigest(reconstructRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + presignDigest, err := requestDigest(presignRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + if reconstructDigest == presignDigest { + t.Fatalf("expected distinct self_v1 digests, got %s", reconstructDigest) + } + + normalizedReconstruct, err := normalizeRouteSubmitRequest(reconstructRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + normalizedPresign, err := normalizeRouteSubmitRequest(presignRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + if normalizedReconstruct.RequestType != RequestTypeReconstruct { + t.Fatalf("expected reconstruct requestType, got %s", normalizedReconstruct.RequestType) + } + if normalizedPresign.RequestType != RequestTypePresignSelfV1 { + t.Fatalf("expected presign requestType, got %s", normalizedPresign.RequestType) + } +} + +func TestRequestDigestUsesDomainSeparation(t *testing.T) { + request := canonicalArtifactApprovalRequest(TemplateQcV1) + + normalizedRequest, err := normalizeRouteSubmitRequest(request, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + payload, err := canonicaljson.Marshal(normalizedRequest) + if err != nil { + t.Fatal(err) + } + + // Manually compute the domain-prefixed digest using the expected domain + // separator constant value. This verifies that the function prepends + // the domain before hashing, preventing cross-context hash collisions. + domainPrefix := "covenant-signer-request-v1:" + prefixedInput := append([]byte(domainPrefix), payload...) + expectedSum := sha256.Sum256(prefixedInput) + expectedDigest := "0x" + hex.EncodeToString(expectedSum[:]) + + // Compute the unprefixed digest to prove domain prefix has effect. + unprefixedSum := sha256.Sum256(payload) + unprefixedDigest := "0x" + hex.EncodeToString(unprefixedSum[:]) + + if expectedDigest == unprefixedDigest { + t.Fatal("domain-prefixed and unprefixed digests should differ") + } + + actualDigest, err := requestDigestFromNormalized(normalizedRequest) + if err != nil { + t.Fatal(err) + } + + if actualDigest != expectedDigest { + t.Fatalf( + "expected domain-prefixed digest %s, got %s", + expectedDigest, + actualDigest, + ) + } + + if actualDigest == unprefixedDigest { + t.Fatal("requestDigestFromNormalized should not produce unprefixed digest") + } +} + +func TestServiceRejectsQcV1PresignRequestType(t *testing.T) { + service, err := NewService(newMemoryHandle(), &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateQcV1) + request.RequestType = RequestTypePresignSelfV1 + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "route_qc_invalid_presign", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil { + t.Fatal("expected requestType validation error") + } + if !strings.Contains(err.Error(), "request.requestType must be reconstruct for qc_v1") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRequestDigestNormalizesMixedCaseArtifactApprovalVariants(t *testing.T) { + for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { + t.Run(string(route), func(t *testing.T) { + canonicalRequest := canonicalMixedCaseCoverageArtifactApprovalRequest(t, route) + mixedCaseRequest := mixedCaseArtifactApprovalVariantFromRequest( + t, + canonicalRequest, + ) + + if mixedCaseRequest.Reserve == canonicalRequest.Reserve { + t.Fatalf( + "expected mixed-case reserve variant, got %s", + mixedCaseRequest.Reserve, + ) + } + + normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + normalizedMixedCase, err := normalizeRouteSubmitRequest(mixedCaseRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(normalizedMixedCase, normalizedCanonical) { + t.Fatalf( + "expected normalized mixed-case request %#v, got %#v", + normalizedCanonical, + normalizedMixedCase, + ) + } + + canonicalDigest, err := requestDigest(canonicalRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + mixedCaseDigest, err := requestDigest(mixedCaseRequest, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + if mixedCaseDigest != canonicalDigest { + t.Fatalf( + "expected matching digest %s, got %s", + canonicalDigest, + mixedCaseDigest, + ) + } + }) + } +} + +func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testing.T) { + testCases := []struct { + name string + request RouteSubmitRequest + plan *MigrationTransactionPlan + expected string + }{ + { + name: "canonical cross-stack vector", + request: RouteSubmitRequest{ + Reserve: "0x2000000000000000000000000000000000000002", + Epoch: 12, + ActiveOutpoint: CovenantOutpoint{TxID: "0x1111111111111111111111111111111111111111111111111111111111111111", Vout: 1}, + DestinationCommitmentHash: "0xf1b1739d99ea890ea6d419d6db28f4d5fe0871c32619a0984c1bfdbe4025f768", + }, + plan: &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, + PlanCommitmentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + InputValueSats: 1_000_000, + DestinationValueSats: 998_000, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 1_670, + InputSequence: canonicalCovenantInputSequence, + LockTime: 950000, + }, + expected: "0x8dcafe57b888040d644e80dfd1b8b089dfd5016205d78316549ef71d032070f2", + }, + { + name: "mixed-case hex inputs normalize before hashing", + request: RouteSubmitRequest{ + Reserve: "0xAbCd00000000000000000000000000000000Ef01", + Epoch: 0, + ActiveOutpoint: CovenantOutpoint{TxID: "0xAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDd", Vout: 0}, + DestinationCommitmentHash: "0xFfEeDdCcBbAa00998877665544332211FfEeDdCcBbAa00998877665544332211", + }, + plan: &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, + PlanCommitmentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + InputValueSats: 100_000, + DestinationValueSats: 99_300, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 370, + InputSequence: canonicalCovenantInputSequence, + LockTime: 1, + }, + expected: "0x626ce76714e04a41a5ec06a96082cac2ebd4d8f687fdc77766ffd9c0d11dac14", + }, + { + name: "max uint32 fields and large safe integer amounts remain stable", + request: RouteSubmitRequest{ + Reserve: "0x9999999999999999999999999999999999999999", + Epoch: 4294967295, + ActiveOutpoint: CovenantOutpoint{TxID: "0x0000000000000000000000000000000000000000000000000000000000000001", Vout: 4294967295}, + DestinationCommitmentHash: "0x00000000000000000000000000000000000000000000000000000000000000aa", + }, + plan: &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, + PlanCommitmentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + InputValueSats: 9_007_199_254_740_000, + DestinationValueSats: 9_007_199_254_737_000, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 2670, + InputSequence: canonicalCovenantInputSequence, + LockTime: 0xffffffff, + }, + expected: "0x42983bef3abb9680093ca0254c780c6ed4e6178405649bf1846ebb381ca89e02", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + actual, err := computeMigrationTransactionPlanCommitmentHash(testCase.request, testCase.plan) + if err != nil { + t.Fatal(err) + } + + if actual != testCase.expected { + t.Fatalf("unexpected plan commitment hash: %s", actual) + } + }) + } +} diff --git a/pkg/covenantsigner/doc.go b/pkg/covenantsigner/doc.go new file mode 100644 index 0000000000..dce57778c4 --- /dev/null +++ b/pkg/covenantsigner/doc.go @@ -0,0 +1,7 @@ +// Package covenantsigner implements keep-core covenant signer substrate slices: +// durable submit/poll semantics, strict request validation, and a compatible +// HTTP surface for covenant recovery/presign signer jobs. The current branch +// also validates the concrete migration destination reservation artifact that +// later real signing flows will consume, together with the canonical +// pre-signed migration transaction plan fields needed before tx construction. +package covenantsigner diff --git a/pkg/covenantsigner/engine.go b/pkg/covenantsigner/engine.go new file mode 100644 index 0000000000..b36ea06ee2 --- /dev/null +++ b/pkg/covenantsigner/engine.go @@ -0,0 +1,51 @@ +package covenantsigner + +import ( + "context" + "errors" +) + +var errJobNotFound = errors.New("covenant signer job not found") + +type Transition struct { + State JobState + Detail string + Reason FailureReason + PSBTHash string + TransactionHex string + Handoff map[string]any +} + +type Engine interface { + OnSubmit(ctx context.Context, job *Job) (*Transition, error) + OnPoll(ctx context.Context, job *Job) (*Transition, error) +} + +type SignerApprovalVerifier interface { + VerifySignerApproval(request RouteSubmitRequest) error +} + +type SignerApprovalVerifierFunc func(request RouteSubmitRequest) error + +func (savf SignerApprovalVerifierFunc) VerifySignerApproval( + request RouteSubmitRequest, +) error { + return savf(request) +} + +type passiveEngine struct{} + +func NewPassiveEngine() Engine { + return &passiveEngine{} +} + +func (pe *passiveEngine) OnSubmit(context.Context, *Job) (*Transition, error) { + return &Transition{ + State: JobStatePending, + Detail: "accepted for covenant signing", + }, nil +} + +func (pe *passiveEngine) OnPoll(context.Context, *Job) (*Transition, error) { + return nil, nil +} diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go new file mode 100644 index 0000000000..4c79dfee64 --- /dev/null +++ b/pkg/covenantsigner/server.go @@ -0,0 +1,410 @@ +package covenantsigner + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-common/pkg/persistence" +) + +var logger = log.Logger("keep-covenant-signer") + +type Server struct { + service *Service + httpServer *http.Server +} + +const maxRequestBodyBytes = 2 << 20 + +func Initialize( + ctx context.Context, + config Config, + handle persistence.BasicHandle, + engine Engine, +) (*Server, bool, error) { + if config.Port == 0 { + return nil, false, nil + } + if config.Port < 0 || config.Port > 65535 { + return nil, false, fmt.Errorf("invalid covenant signer port [%d]", config.Port) + } + + listenAddress := config.ListenAddress + if strings.TrimSpace(listenAddress) == "" { + listenAddress = DefaultListenAddress + } + + if !isLoopbackListenAddress(listenAddress) && strings.TrimSpace(config.AuthToken) == "" { + return nil, false, fmt.Errorf( + "covenant signer authToken is required for non-loopback listenAddress [%s]", + listenAddress, + ) + } + + service, err := NewService( + handle, + engine, + WithDataDir(config.DataDir), + WithMigrationPlanQuoteTrustRoots(config.MigrationPlanQuoteTrustRoots), + WithDepositorTrustRoots(config.DepositorTrustRoots), + WithCustodianTrustRoots(config.CustodianTrustRoots), + ) + if err != nil { + return nil, false, err + } + if err := validateRequiredApprovalTrustRoots(config, service); err != nil { + return nil, false, err + } + if service.signerApprovalVerifier == nil { + logger.Warn( + "covenant signer started without a signer approval verifier; " + + "structured signerApproval certificates will not be verified and " + + "requests without signerApproval will be accepted", + ) + } + if config.EnableSelfV1 && + !hasDepositorTrustRootForRoute( + service.depositorTrustRoots, + TemplateSelfV1, + ) { + logger.Warn( + "covenant signer self_v1 routes are enabled without depositorTrustRoots; " + + "self_v1 depositor approvals still rely on request-supplied scriptTemplate keys", + ) + } + if !hasDepositorTrustRootForRoute( + service.depositorTrustRoots, + TemplateQcV1, + ) { + logger.Warn( + "covenant signer started without qc_v1 depositorTrustRoots; " + + "qc_v1 depositor approvals still rely on request-supplied scriptTemplate keys", + ) + } + if !hasCustodianTrustRootForRoute( + service.custodianTrustRoots, + TemplateQcV1, + ) { + logger.Warn( + "covenant signer started without custodianTrustRoots; " + + "qc_v1 custodian approvals still rely on request-supplied scriptTemplate keys", + ) + } + + server := &Server{ + service: service, + httpServer: &http.Server{ + Addr: net.JoinHostPort(listenAddress, strconv.Itoa(config.Port)), + Handler: newHandler(service, config.AuthToken, config.EnableSelfV1), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 13, + }, + } + + listener, err := net.Listen("tcp", server.httpServer.Addr) + if err != nil { + return nil, false, fmt.Errorf("failed to bind covenant signer port [%d]: %w", config.Port, err) + } + + go func() { + <-ctx.Done() + shutdownCtx, cancelShutdown := context.WithTimeout( + context.WithoutCancel(ctx), + 5*time.Second, + ) + defer cancelShutdown() + + _ = server.httpServer.Shutdown(shutdownCtx) + _ = server.service.Close() + }() + + go func() { + if err := server.httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Errorf("covenant signer server failed: [%v]", err) + } + }() + + logger.Infof( + "enabled covenant signer provider endpoint on [%v] auth=[%v] self_v1=[%v]", + server.httpServer.Addr, + strings.TrimSpace(config.AuthToken) != "", + config.EnableSelfV1, + ) + + return server, true, nil +} + +func validateRequiredApprovalTrustRoots( + config Config, + service *Service, +) error { + if !config.RequireApprovalTrustRoots { + return nil + } + + if config.EnableSelfV1 && + !hasDepositorTrustRootForRoute( + service.depositorTrustRoots, + TemplateSelfV1, + ) { + return fmt.Errorf( + "covenant signer self_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) + } + + if !hasDepositorTrustRootForRoute( + service.depositorTrustRoots, + TemplateQcV1, + ) { + return fmt.Errorf( + "covenant signer qc_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) + } + + if !hasCustodianTrustRootForRoute( + service.custodianTrustRoots, + TemplateQcV1, + ) { + return fmt.Errorf( + "covenant signer qc_v1 routes require custodianTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) + } + + if service.signerApprovalVerifier == nil { + return fmt.Errorf( + "covenant signer requires a signerApprovalVerifier when covenantSigner.requireApprovalTrustRoots=true", + ) + } + + return nil +} + +func hasDepositorTrustRootForRoute( + trustRoots []DepositorTrustRoot, + route TemplateID, +) bool { + for _, trustRoot := range trustRoots { + if trustRoot.Route == route { + return true + } + } + + return false +} + +func hasCustodianTrustRootForRoute( + trustRoots []CustodianTrustRoot, + route TemplateID, +) bool { + for _, trustRoot := range trustRoots { + if trustRoot.Route == route { + return true + } + } + + return false +} + +func newHandler(service *Service, authToken string, enableSelfV1 bool) http.Handler { + mux := http.NewServeMux() + protectedHandler := withBearerAuth(mux, authToken) + + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + + mux.HandleFunc("POST /v1/qc_v1/signer/requests", submitHandler(service, TemplateQcV1)) + mux.HandleFunc("POST /v1/qc_v1/signer/requests:poll", pollBodyHandler(service, TemplateQcV1)) + mux.HandleFunc("/v1/qc_v1/signer/requests/", pollPathHandler(service, TemplateQcV1)) + if enableSelfV1 { + mux.HandleFunc("POST /v1/self_v1/signer/requests", submitHandler(service, TemplateSelfV1)) + mux.HandleFunc("POST /v1/self_v1/signer/requests:poll", pollBodyHandler(service, TemplateSelfV1)) + mux.HandleFunc("/v1/self_v1/signer/requests/", pollPathHandler(service, TemplateSelfV1)) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + mux.ServeHTTP(w, r) + return + } + + protectedHandler.ServeHTTP(w, r) + }) +} + +func isLoopbackListenAddress(address string) bool { + trimmedAddress := strings.TrimSpace(address) + if trimmedAddress == "" || strings.EqualFold(trimmedAddress, "localhost") { + return true + } + + normalizedAddress := trimmedAddress + if strings.HasPrefix(normalizedAddress, "[") && strings.HasSuffix(normalizedAddress, "]") { + normalizedAddress = normalizedAddress[1 : len(normalizedAddress)-1] + } + + ip := net.ParseIP(normalizedAddress) + return ip != nil && ip.IsLoopback() +} + +func withBearerAuth(next http.Handler, authToken string) http.Handler { + trimmedToken := strings.TrimSpace(authToken) + if trimmedToken == "" { + return next + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorizationHeader := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(authorizationHeader, prefix) { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "missing bearer token", http.StatusUnauthorized) + return + } + + presentedToken := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, prefix)) + if subtle.ConstantTimeCompare([]byte(presentedToken), []byte(trimmedToken)) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "invalid bearer token", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} + +func decodeJSON[T any](w http.ResponseWriter, r *http.Request, target *T) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) + defer r.Body.Close() + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + http.Error(w, "malformed request body", http.StatusBadRequest) + return false + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + http.Error(w, "malformed request body", http.StatusBadRequest) + return false + } + + return true +} + +func writeJSON(w http.ResponseWriter, statusCode int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(payload) +} + +func handleError(w http.ResponseWriter, err error) { + var inputErr *inputError + if errors.As(err, &inputErr) { + http.Error(w, inputErr.Error(), http.StatusBadRequest) + return + } + if errors.Is(err, errJobNotFound) { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + logger.Errorf("covenant signer request failed: [%v]", err) + http.Error(w, "internal server error", http.StatusInternalServerError) +} + +func submitHandler(service *Service, route TemplateID) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + input := SignerSubmitInput{} + if !decodeJSON(w, r, &input) { + return + } + + // Detach from the HTTP request lifetime so that threshold signing + // survives write-timeout and client disconnects. + result, err := service.Submit(context.WithoutCancel(r.Context()), route, input) + if err != nil { + handleError(w, err) + return + } + + writeJSON(w, http.StatusOK, result) + } +} + +func pollBodyHandler(service *Service, route TemplateID) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + input := SignerPollInput{} + if !decodeJSON(w, r, &input) { + return + } + + result, err := service.Poll(r.Context(), route, input) + if err != nil { + handleError(w, err) + return + } + + writeJSON(w, http.StatusOK, result) + } +} + +func pollPathHandler(service *Service, route TemplateID) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + prefix := "/v1/" + string(route) + "/signer/requests/" + if !strings.HasPrefix(r.URL.Path, prefix) || !strings.HasSuffix(r.URL.Path, ":poll") { + http.NotFound(w, r) + return + } + + rawPathRequestID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, prefix), ":poll") + pathRequestID, err := url.PathUnescape(rawPathRequestID) + if err != nil { + http.NotFound(w, r) + return + } + if pathRequestID == "" || strings.Contains(pathRequestID, "/") { + http.NotFound(w, r) + return + } + + input := SignerPollInput{} + if !decodeJSON(w, r, &input) { + return + } + if input.RequestID != "" && input.RequestID != pathRequestID { + http.Error(w, "requestId in body does not match path", http.StatusBadRequest) + return + } + input.RequestID = pathRequestID + + result, err := service.Poll(r.Context(), route, input) + if err != nil { + handleError(w, err) + return + } + + writeJSON(w, http.StatusOK, result) + } +} diff --git a/pkg/covenantsigner/server_test.go b/pkg/covenantsigner/server_test.go new file mode 100644 index 0000000000..97e210691c --- /dev/null +++ b/pkg/covenantsigner/server_test.go @@ -0,0 +1,934 @@ +package covenantsigner + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +type scriptedVerifierEngine struct { + scriptedEngine +} + +func (sve *scriptedVerifierEngine) VerifySignerApproval(RouteSubmitRequest) error { + return nil +} + +func TestServerHandlesSubmitAndPathPoll(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "", true)) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", bytes.NewReader(submitPayload)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } + + submitResult := StepResult{} + if err := json.NewDecoder(response.Body).Decode(&submitResult); err != nil { + t.Fatal(err) + } + + pollPayload := mustJSON(t, SignerPollInput{ + RouteRequestID: "ors_http", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + pollResponse, err := http.Post(server.URL+"/v1/self_v1/signer/requests/"+submitResult.RequestID+":poll", "application/json", bytes.NewReader(pollPayload)) + if err != nil { + t.Fatal(err) + } + defer pollResponse.Body.Close() + + if pollResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(pollResponse.Body) + t.Fatalf("unexpected poll status: %d %s", pollResponse.StatusCode, string(body)) + } +} + +func TestServerRejectsUnknownFieldsOnSubmit(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "", true)) + defer server.Close() + + base := baseRequest(TemplateSelfV1) + template := &SelfV1Template{} + if err := strictUnmarshal(base.ScriptTemplate, template); err != nil { + t.Fatal(err) + } + payload := bytes.NewBufferString(fmt.Sprintf(`{ + "routeRequestId":"ors_http_unknown", + "stage":"SIGNER_COORDINATION", + "request":{ + "facadeRequestId":"rf_123", + "idempotencyKey":"idem_123", + "route":"self_v1", + "requestType":"reconstruct", + "strategy":"0x1234", + "reserve":"0x1111111111111111111111111111111111111111", + "epoch":12, + "maturityHeight":912345, + "activeOutpoint":{"txid":"0x0102","vout":1,"scriptHash":"0x0304"}, + "destinationCommitmentHash":"%s", + "migrationDestination":{ + "reservationId":"cmdr_12345678", + "reserve":"0x1111111111111111111111111111111111111111", + "epoch":12, + "route":"MIGRATION", + "revealer":"0x2222222222222222222222222222222222222222", + "vault":"0x3333333333333333333333333333333333333333", + "network":"regtest", + "status":"RESERVED", + "depositScript":"0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "depositScriptHash":"0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", + "migrationExtraData":"0x41435f4d49475241544556312222222222222222222222222222222222222222", + "destinationCommitmentHash":"%s" + }, + "migrationTransactionPlan":{ + "planVersion":1, + "planCommitmentHash":"%s", + "inputValueSats":1000000, + "destinationValueSats":998000, + "anchorValueSats":330, + "feeSats":1670, + "inputSequence":4294967293, + "lockTime":912345 + }, + "artifactApprovals":{ + "payload":{ + "approvalVersion":1, + "route":"self_v1", + "scriptTemplateId":"self_v1", + "destinationCommitmentHash":"%s", + "planCommitmentHash":"%s" + }, + "approvals":[ + {"role":"D","signature":"%s"} + ] + }, + "artifactSignatures":["%s"], + "artifacts":{}, + "scriptTemplate":{"template":"self_v1","depositorPublicKey":"%s","signerPublicKey":"%s","delta2":4320}, + "signing":{"signerRequired":true,"custodianRequired":false}, + "futureField":"ignored" + }, + "futureTopLevel":"ignored" + }`, + base.DestinationCommitmentHash, + base.DestinationCommitmentHash, + base.MigrationTransactionPlan.PlanCommitmentHash, + base.ArtifactApprovals.Payload.DestinationCommitmentHash, + base.ArtifactApprovals.Payload.PlanCommitmentHash, + base.ArtifactApprovals.Approvals[0].Signature, + base.ArtifactSignatures[0], + template.DepositorPublicKey, + template.SignerPublicKey, + )) + + response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", payload) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } + + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), "malformed request body") { + t.Fatalf("unexpected response body: %s", string(body)) + } +} + +func TestServerRejectsTrailingJSONOnSubmit(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "", true)) + defer server.Close() + + validPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_trailing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + payload := append(validPayload, []byte(`{"unexpected":"trailing"}`)...) + + response, err := http.Post( + server.URL+"/v1/self_v1/signer/requests", + "application/json", + bytes.NewReader(payload), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } + + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), "malformed request body") { + t.Fatalf("unexpected response body: %s", string(body)) + } +} + +func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if _, enabled, err := Initialize(ctx, Config{Port: -1}, handle, nil); err == nil || enabled { + t.Fatalf("expected invalid negative port to fail, got enabled=%v err=%v", enabled, err) + } + if _, enabled, err := Initialize( + ctx, + Config{Port: 9711, ListenAddress: "0.0.0.0"}, + handle, + nil, + ); err == nil || enabled { + t.Fatalf("expected non-loopback bind without auth token to fail, got enabled=%v err=%v", enabled, err) + } + + listener, err := net.Listen("tcp", net.JoinHostPort(DefaultListenAddress, "0")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + if _, enabled, err := Initialize( + ctx, + Config{Port: port, ListenAddress: DefaultListenAddress}, + handle, + nil, + ); err == nil || enabled { + t.Fatalf("expected occupied port to fail, got enabled=%v err=%v", enabled, err) + } +} + +func availableLoopbackPort(t *testing.T) int { + t.Helper() + + listener, err := net.Listen("tcp", net.JoinHostPort(DefaultListenAddress, "0")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + return listener.Addr().(*net.TCPAddr).Port +} + +func TestInitializeRequiresQcV1DepositorTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + RequireApprovalTrustRoots: true, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected missing qc_v1 depositor trust roots to fail, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "covenant signer qc_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeRequiresQcV1CustodianTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected missing qc_v1 custodian trust roots to fail, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "covenant signer qc_v1 routes require custodianTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeRequiresSelfV1DepositorTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + EnableSelfV1: true, + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }, + CustodianTrustRoots: []CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected missing self_v1 depositor trust roots to fail, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "covenant signer self_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeAcceptsRequiredApprovalTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + server, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + EnableSelfV1: true, + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + testDepositorTrustRoot(TemplateSelfV1), + }, + CustodianTrustRoots: []CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedVerifierEngine{}, + ) + if err != nil || !enabled || server == nil { + t.Fatalf("expected startup to succeed with required trust roots, got enabled=%v server=%v err=%v", enabled, server != nil, err) + } +} + +func TestInitializeRequiresSignerApprovalVerifierWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + EnableSelfV1: true, + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + testDepositorTrustRoot(TemplateSelfV1), + }, + CustodianTrustRoots: []CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected startup to fail without signer approval verifier, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "requires a signerApprovalVerifier when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestIsLoopbackListenAddressAcceptsBracketedIPv6Loopback(t *testing.T) { + if !isLoopbackListenAddress("[::1]") { + t.Fatal("expected bracketed IPv6 loopback address to be recognized") + } +} + +func TestServerRequiresBearerTokenWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "test-token", true)) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_auth", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + response, err := http.Get(server.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("unexpected healthz status: %d", response.StatusCode) + } + + request, err := http.NewRequest( + http.MethodPost, + server.URL+"/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + + response, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusUnauthorized { + body, _ := io.ReadAll(response.Body) + t.Fatalf("expected unauthorized submit without bearer token, got %d %s", response.StatusCode, string(body)) + } + + authorizedRequest, err := http.NewRequest( + http.MethodPost, + server.URL+"/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + authorizedRequest.Header.Set("Content-Type", "application/json") + authorizedRequest.Header.Set("Authorization", "Bearer test-token") + + authorizedResponse, err := http.DefaultClient.Do(authorizedRequest) + if err != nil { + t.Fatal(err) + } + defer authorizedResponse.Body.Close() + + if authorizedResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(authorizedResponse.Body) + t.Fatalf("unexpected authorized submit status: %d %s", authorizedResponse.StatusCode, string(body)) + } +} + +func TestServerCanKeepSelfV1RoutesDark(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "", false)) + defer server.Close() + + response, err := http.Post( + server.URL+"/v1/self_v1/signer/requests", + "application/json", + bytes.NewReader(mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_self_dark", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + })), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(response.Body) + t.Fatalf("expected disabled self_v1 route to return 404, got %d %s", response.StatusCode, string(body)) + } + + qcResponse, err := http.Post( + server.URL+"/v1/qc_v1/signer/requests", + "application/json", + bytes.NewReader(mustJSON(t, SignerSubmitInput{ + RouteRequestID: "orq_http_qc", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + })), + ) + if err != nil { + t.Fatal(err) + } + defer qcResponse.Body.Close() + + if qcResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(qcResponse.Body) + t.Fatalf("expected qc_v1 route to remain available, got %d %s", qcResponse.StatusCode, string(body)) + } +} + +func TestServerBoundaryErrorMatrix(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "test-token", true)) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_matrix", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + mismatchedPollPayload := mustJSON(t, SignerPollInput{ + RequestID: "different_id", + RouteRequestID: "ors_http_matrix", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + oversizedBody := []byte( + `{"routeRequestId":"ors_big","stage":"SIGNER_COORDINATION","request":{"facadeRequestId":"` + + strings.Repeat("a", maxRequestBodyBytes+1) + `"}}`, + ) + + testCases := []struct { + name string + method string + path string + body []byte + authHeader string + wantStatus int + wantBodyContains string + wantAllow string + }{ + { + name: "invalid bearer token", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests", + body: submitPayload, + authHeader: "Bearer wrong-token", + wantStatus: http.StatusUnauthorized, + wantBodyContains: "invalid bearer token", + }, + { + name: "method mismatch on poll path returns 405", + method: http.MethodGet, + path: "/v1/self_v1/signer/requests/request_1:poll", + authHeader: "Bearer test-token", + wantStatus: http.StatusMethodNotAllowed, + wantBodyContains: "method not allowed", + wantAllow: http.MethodPost, + }, + { + name: "unknown fields in envelope rejected", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests", + body: []byte(`{"routeRequestId":"ors_http_unknown","stage":"SIGNER_COORDINATION","request":{},"futureTopLevel":"ignored"}`), + authHeader: "Bearer test-token", + wantStatus: http.StatusBadRequest, + wantBodyContains: "malformed request body", + }, + { + name: "oversized body rejected", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests", + body: oversizedBody, + authHeader: "Bearer test-token", + wantStatus: http.StatusBadRequest, + wantBodyContains: "malformed request body", + }, + { + name: "poll path and body request id mismatch rejected", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests/request_from_path:poll", + body: mismatchedPollPayload, + authHeader: "Bearer test-token", + wantStatus: http.StatusBadRequest, + wantBodyContains: "requestId in body does not match path", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + request, err := http.NewRequest( + tc.method, + server.URL+tc.path, + bytes.NewReader(tc.body), + ) + if err != nil { + t.Fatal(err) + } + + if tc.body != nil { + request.Header.Set("Content-Type", "application/json") + } + if tc.authHeader != "" { + request.Header.Set("Authorization", tc.authHeader) + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != tc.wantStatus { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected status: %d body: %s", response.StatusCode, string(body)) + } + + if tc.wantAllow != "" && response.Header.Get("Allow") != tc.wantAllow { + t.Fatalf("unexpected Allow header: %q", response.Header.Get("Allow")) + } + + if tc.wantBodyContains != "" { + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), tc.wantBodyContains) { + t.Fatalf("expected body to contain %q, got %q", tc.wantBodyContains, string(body)) + } + } + }) + } +} + +// contextCapturingEngine is a test engine that passes the context through +// to its submit function, unlike scriptedEngine which drops it. This allows +// tests to verify context propagation behavior in the submit handler. +type contextCapturingEngine struct { + submit func(ctx context.Context, job *Job) (*Transition, error) +} + +func (cce *contextCapturingEngine) OnSubmit(ctx context.Context, job *Job) (*Transition, error) { + if cce.submit == nil { + return nil, nil + } + return cce.submit(ctx, job) +} + +func (cce *contextCapturingEngine) OnPoll(context.Context, *Job) (*Transition, error) { + return nil, nil +} + +func TestSubmitHandlerDetachesContextFromHTTPLifecycle(t *testing.T) { + // Channels for synchronizing the engine mock with the test goroutine. + engineStarted := make(chan struct{}) + proceedCh := make(chan struct{}) + + var capturedCtxErr error + var mu sync.Mutex + + handle := newMemoryHandle() + engine := &contextCapturingEngine{ + submit: func(ctx context.Context, job *Job) (*Transition, error) { + // Signal that OnSubmit has started executing. + close(engineStarted) + // Wait for the test to cancel the HTTP request context. + <-proceedCh + // Capture whether the context received by OnSubmit was cancelled. + mu.Lock() + capturedCtxErr = ctx.Err() + mu.Unlock() + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + } + + service, err := NewService(handle, engine) + if err != nil { + t.Fatal(err) + } + + handler := newHandler(service, "", true) + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_detach_cancel", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + // Use a cancellable context for the HTTP request to simulate the HTTP + // write timeout or client disconnect that cancels r.Context(). + reqCtx, reqCancel := context.WithCancel(context.Background()) + defer reqCancel() + + req, err := http.NewRequestWithContext( + reqCtx, + http.MethodPost, + "/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + recorder := httptest.NewRecorder() + + // Serve the request in a goroutine because the engine will block inside + // OnSubmit until we signal proceedCh. + serveDone := make(chan struct{}) + go func() { + handler.ServeHTTP(recorder, req) + close(serveDone) + }() + + // Wait for the engine to start processing the submit. + select { + case <-engineStarted: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for engine to start") + } + + // Cancel the request context, simulating a client disconnect or HTTP + // write timeout expiring while signing is in progress. + reqCancel() + + // Allow the engine to proceed and check the context it received. + close(proceedCh) + + // Wait for the handler to finish. + select { + case <-serveDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for handler to finish") + } + + // The critical assertion: the context received by OnSubmit must NOT have + // been cancelled even though the HTTP request context was. + mu.Lock() + defer mu.Unlock() + if capturedCtxErr != nil { + t.Fatalf( + "expected OnSubmit context to be non-cancelled after HTTP "+ + "request cancellation, but got: %v", + capturedCtxErr, + ) + } +} + +func TestSubmitHandlerPreCancelledContextStillSucceeds(t *testing.T) { + var capturedCtxErr error + var mu sync.Mutex + + handle := newMemoryHandle() + engine := &contextCapturingEngine{ + submit: func(ctx context.Context, job *Job) (*Transition, error) { + mu.Lock() + capturedCtxErr = ctx.Err() + mu.Unlock() + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + } + + service, err := NewService(handle, engine) + if err != nil { + t.Fatal(err) + } + + // Test through the handler directly using httptest.ResponseRecorder + // because an HTTP client would fail to send a request with a + // pre-cancelled context. + handler := newHandler(service, "", true) + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_detach_precancel", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + // Create a pre-cancelled context. + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + req, err := http.NewRequestWithContext( + cancelledCtx, + http.MethodPost, + "/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + // The handler should still succeed because the context passed to + // service.Submit is detached from the HTTP request context. + if recorder.Code != http.StatusOK { + t.Fatalf( + "expected 200 OK with pre-cancelled context, got %d: %s", + recorder.Code, + recorder.Body.String(), + ) + } + + mu.Lock() + defer mu.Unlock() + if capturedCtxErr != nil { + t.Fatalf( + "expected OnSubmit context to be non-cancelled with pre-cancelled "+ + "HTTP context, but got: %v", + capturedCtxErr, + ) + } +} + +type contextKey string + +func TestSubmitHandlerPreservesContextValues(t *testing.T) { + const testKey contextKey = "test-trace-id" + const testValue = "trace-abc-123" + + var capturedValue any + var mu sync.Mutex + + handle := newMemoryHandle() + engine := &contextCapturingEngine{ + submit: func(ctx context.Context, job *Job) (*Transition, error) { + mu.Lock() + capturedValue = ctx.Value(testKey) + mu.Unlock() + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + } + + service, err := NewService(handle, engine) + if err != nil { + t.Fatal(err) + } + + // Wrap the handler with middleware that injects a value into the request + // context. The detached context should preserve this value. + innerHandler := newHandler(service, "", true) + wrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + enrichedCtx := context.WithValue(r.Context(), testKey, testValue) + innerHandler.ServeHTTP(w, r.WithContext(enrichedCtx)) + }) + + server := httptest.NewServer(wrappedHandler) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_detach_values", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + response, err := http.Post( + server.URL+"/v1/self_v1/signer/requests", + "application/json", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } + + mu.Lock() + defer mu.Unlock() + if capturedValue != testValue { + t.Fatalf( + "expected context value %q to be preserved through detachment, "+ + "got %v", + testValue, + capturedValue, + ) + } +} diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go new file mode 100644 index 0000000000..3d112f9e3f --- /dev/null +++ b/pkg/covenantsigner/service.go @@ -0,0 +1,431 @@ +package covenantsigner + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "reflect" + "sync" + "time" + + "github.com/keep-network/keep-common/pkg/persistence" +) + +type Service struct { + store *Store + engine Engine + signerApprovalVerifier SignerApprovalVerifier + now func() time.Time + mutex sync.Mutex + dataDir string + migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot + depositorTrustRoots []DepositorTrustRoot + custodianTrustRoots []CustodianTrustRoot +} + +type ServiceOption func(*Service) + +func WithMigrationPlanQuoteTrustRoots( + trustRoots []MigrationPlanQuoteTrustRoot, +) ServiceOption { + cloned := append([]MigrationPlanQuoteTrustRoot{}, trustRoots...) + + return func(service *Service) { + service.migrationPlanQuoteTrustRoots = cloned + } +} + +func WithDepositorTrustRoots( + trustRoots []DepositorTrustRoot, +) ServiceOption { + cloned := append([]DepositorTrustRoot{}, trustRoots...) + + return func(service *Service) { + service.depositorTrustRoots = cloned + } +} + +func WithCustodianTrustRoots( + trustRoots []CustodianTrustRoot, +) ServiceOption { + cloned := append([]CustodianTrustRoot{}, trustRoots...) + + return func(service *Service) { + service.custodianTrustRoots = cloned + } +} + +func WithSignerApprovalVerifier( + verifier SignerApprovalVerifier, +) ServiceOption { + return func(service *Service) { + service.signerApprovalVerifier = verifier + } +} + +// WithDataDir sets the data directory path for file-level locking. When +// provided, the store acquires an exclusive advisory lock to prevent +// concurrent process corruption. When empty, file locking is skipped. +func WithDataDir(dataDir string) ServiceOption { + return func(service *Service) { + service.dataDir = dataDir + } +} + +func NewService( + handle persistence.BasicHandle, + engine Engine, + options ...ServiceOption, +) (*Service, error) { + if engine == nil { + engine = NewPassiveEngine() + } + + service := &Service{ + engine: engine, + now: func() time.Time { return time.Now().UTC() }, + } + if verifier, ok := engine.(SignerApprovalVerifier); ok { + service.signerApprovalVerifier = verifier + } + for _, option := range options { + option(service) + } + + store, err := NewStore(handle, service.dataDir) + if err != nil { + return nil, err + } + service.store = store + + normalizedDepositorTrustRoots, err := normalizeDepositorTrustRoots( + service.depositorTrustRoots, + ) + if err != nil { + return nil, err + } + service.depositorTrustRoots = normalizedDepositorTrustRoots + + normalizedCustodianTrustRoots, err := normalizeCustodianTrustRoots( + service.custodianTrustRoots, + ) + if err != nil { + return nil, err + } + service.custodianTrustRoots = normalizedCustodianTrustRoots + + return service, nil +} + +func newRequestID(prefix string) (string, error) { + randomBytes := make([]byte, 8) + if _, err := rand.Read(randomBytes); err != nil { + return "", err + } + + return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(randomBytes)), nil +} + +func applyTransition(job *Job, transition *Transition, now time.Time) { + if transition == nil { + return + } + + job.State = transition.State + job.Detail = transition.Detail + job.Reason = transition.Reason + job.PSBTHash = transition.PSBTHash + job.TransactionHex = transition.TransactionHex + job.Handoff = transition.Handoff + job.UpdatedAt = now.Format(time.RFC3339Nano) + + switch transition.State { + case JobStateArtifactReady, JobStateHandoffReady: + job.CompletedAt = job.UpdatedAt + job.FailedAt = "" + case JobStateFailed: + job.FailedAt = job.UpdatedAt + } +} + +func mapJobResult(job *Job) StepResult { + switch job.State { + case JobStateArtifactReady: + return StepResult{ + Status: StepStatusReady, + RequestID: job.RequestID, + Detail: job.Detail, + PSBTHash: job.PSBTHash, + TransactionHex: job.TransactionHex, + } + case JobStateHandoffReady: + return StepResult{ + Status: StepStatusReady, + RequestID: job.RequestID, + Detail: job.Detail, + Handoff: job.Handoff, + } + case JobStateFailed: + return StepResult{ + Status: StepStatusFailed, + RequestID: job.RequestID, + Detail: job.Detail, + Reason: job.Reason, + } + default: + return StepResult{ + Status: StepStatusPending, + RequestID: job.RequestID, + Detail: job.Detail, + } + } +} + +func isTerminalJobState(state JobState) bool { + return state == JobStateArtifactReady || + state == JobStateHandoffReady || + state == JobStateFailed +} + +func sameJobRevision(current *Job, snapshot *Job) bool { + return current.RequestID == snapshot.RequestID && + current.State == snapshot.State && + current.Detail == snapshot.Detail && + current.Reason == snapshot.Reason && + current.PSBTHash == snapshot.PSBTHash && + current.TransactionHex == snapshot.TransactionHex && + current.UpdatedAt == snapshot.UpdatedAt && + current.CompletedAt == snapshot.CompletedAt && + current.FailedAt == snapshot.FailedAt && + reflect.DeepEqual(current.Handoff, snapshot.Handoff) +} + +func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, error) { + job, ok, err := s.store.GetByRequestID(input.RequestID) + if err != nil { + return nil, err + } + if !ok || job.Route != route { + return nil, errJobNotFound + } + if job.RouteRequestID != input.RouteRequestID { + return nil, &inputError{"routeRequestId does not match stored job"} + } + + digest, err := requestDigest( + input.Request, + validationOptions{ + policyIndependentDigest: true, + }, + ) + if err != nil { + return nil, err + } + if digest != job.RequestDigest { + return nil, &inputError{"request does not match stored job payload"} + } + + return job, nil +} + +func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubmitInput) (StepResult, error) { + submitValidationOptions := validationOptions{ + migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + depositorTrustRoots: s.depositorTrustRoots, + custodianTrustRoots: s.custodianTrustRoots, + requireFreshMigrationPlanQuote: true, + migrationPlanQuoteVerificationNow: s.now(), + signerApprovalVerifier: s.signerApprovalVerifier, + } + if err := validateSubmitInput(route, input, submitValidationOptions); err != nil { + return StepResult{}, err + } + + normalizedRequest, err := normalizeRouteSubmitRequest( + input.Request, + validationOptions{ + migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + depositorTrustRoots: s.depositorTrustRoots, + custodianTrustRoots: s.custodianTrustRoots, + signerApprovalVerifier: s.signerApprovalVerifier, + }, + ) + if err != nil { + return StepResult{}, err + } + + requestDigest, err := requestDigestFromNormalized(normalizedRequest) + if err != nil { + return StepResult{}, err + } + + s.mutex.Lock() + if existing, ok, err := s.store.GetByRouteRequest(route, input.RouteRequestID); err != nil { + s.mutex.Unlock() + return StepResult{}, err + } else if ok { + if existing.RequestDigest != requestDigest { + s.mutex.Unlock() + return StepResult{}, &inputError{ + "routeRequestId already exists with a different request payload", + } + } + s.mutex.Unlock() + return mapJobResult(existing), nil + } + + requestIDPrefix := "" + switch route { + case TemplateQcV1: + requestIDPrefix = "kcs_qc" + case TemplateSelfV1: + requestIDPrefix = "kcs_self" + default: + s.mutex.Unlock() + return StepResult{}, fmt.Errorf("unsupported route: %s", route) + } + + requestID, err := newRequestID(requestIDPrefix) + if err != nil { + s.mutex.Unlock() + return StepResult{}, err + } + + now := s.now() + + job := &Job{ + RequestID: requestID, + RouteRequestID: input.RouteRequestID, + Route: route, + IdempotencyKey: input.Request.IdempotencyKey, + FacadeRequestID: input.Request.FacadeRequestID, + RequestDigest: requestDigest, + State: JobStateSubmitted, + Detail: "accepted for covenant signing", + CreatedAt: now.Format(time.RFC3339Nano), + UpdatedAt: now.Format(time.RFC3339Nano), + Request: normalizedRequest, + } + + if err := s.store.Put(job); err != nil { + s.mutex.Unlock() + return StepResult{}, err + } + s.mutex.Unlock() + + transition, err := s.engine.OnSubmit(ctx, job) + if err != nil { + return StepResult{}, err + } + + if transition == nil { + transition = &Transition{ + State: JobStatePending, + Detail: "accepted for covenant signing", + } + } + + s.mutex.Lock() + defer s.mutex.Unlock() + + currentJob, ok, err := s.store.GetByRequestID(requestID) + if err != nil { + return StepResult{}, err + } + if !ok { + return StepResult{}, errJobNotFound + } + + // Another poll already advanced the stored job while submit was waiting on + // signer work. Return the newer durable state instead of overwriting it with + // a transition computed from an older snapshot. + if !sameJobRevision(currentJob, job) || isTerminalJobState(currentJob.State) { + return mapJobResult(currentJob), nil + } + + applyTransition(currentJob, transition, s.now()) + if err := s.store.Put(currentJob); err != nil { + return StepResult{}, err + } + + return mapJobResult(currentJob), nil +} + +func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollInput) (StepResult, error) { + if err := validatePollInput( + route, + input, + validationOptions{ + policyIndependentDigest: true, + }, + ); err != nil { + return StepResult{}, err + } + + s.mutex.Lock() + job, err := s.loadPollJob(route, input) + if err != nil { + s.mutex.Unlock() + return StepResult{}, err + } + if isTerminalJobState(job.State) { + result := mapJobResult(job) + s.mutex.Unlock() + return result, nil + } + s.mutex.Unlock() + + transition, pollErr := s.engine.OnPoll(ctx, job) + if pollErr != nil { + if pollErr != errJobNotFound { + return StepResult{}, pollErr + } + } + + s.mutex.Lock() + defer s.mutex.Unlock() + + currentJob, err := s.loadPollJob(route, input) + if err != nil { + return StepResult{}, err + } + + // Another Submit/Poll already advanced the stored job while this poll was + // in-flight. Return the newer durable state instead of overwriting it with a + // stale transition computed from an older snapshot. + if !sameJobRevision(currentJob, job) || isTerminalJobState(currentJob.State) { + return mapJobResult(currentJob), nil + } + + if pollErr == errJobNotFound { + applyTransition(currentJob, &Transition{ + State: JobStateFailed, + Reason: ReasonJobNotFound, + Detail: "signer job no longer exists", + }, s.now()) + if storeErr := s.store.Put(currentJob); storeErr != nil { + return StepResult{}, storeErr + } + return mapJobResult(currentJob), nil + } + + if transition != nil { + applyTransition(currentJob, transition, s.now()) + if err := s.store.Put(currentJob); err != nil { + return StepResult{}, err + } + } + + return mapJobResult(currentJob), nil +} + +// Close releases the resources held by the service, including the store's +// exclusive file lock when one was acquired. +func (s *Service) Close() error { + if s.store != nil { + return s.store.Close() + } + + return nil +} diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go new file mode 100644 index 0000000000..fa8e74db29 --- /dev/null +++ b/pkg/covenantsigner/store.go @@ -0,0 +1,285 @@ +package covenantsigner + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/keep-network/keep-common/pkg/persistence" +) + +const jobsDirectory = "covenant-signer/jobs" +const lockFileName = ".lock" + +type Store struct { + handle persistence.BasicHandle + mutex sync.Mutex + lockFile *os.File + byRequestID map[string]*Job + byRouteKey map[string]string +} + +// NewStore creates a new Store backed by the given persistence handle. When +// dataDir is non-empty, an exclusive advisory file lock is acquired on a lock +// file inside the jobs directory to prevent concurrent process corruption. If +// the lock cannot be acquired (another process holds it), NewStore returns an +// error. When dataDir is empty (in-memory handles), file locking is skipped. +func NewStore(handle persistence.BasicHandle, dataDir string) (*Store, error) { + store := &Store{ + handle: handle, + byRequestID: make(map[string]*Job), + byRouteKey: make(map[string]string), + } + + if dataDir != "" { + lockFile, err := acquireFileLock(dataDir) + if err != nil { + return nil, err + } + store.lockFile = lockFile + } + + if err := store.load(); err != nil { + // Release the lock if loading fails after successful acquisition. + store.Close() + return nil, err + } + + return store, nil +} + +// acquireFileLock creates and acquires an exclusive non-blocking advisory lock +// on a lock file inside the jobs directory. The returned file handle must be +// kept open for the lifetime of the lock; closing it releases the lock. +func acquireFileLock(dataDir string) (*os.File, error) { + lockPath := filepath.Join(dataDir, jobsDirectory, lockFileName) + + if err := os.MkdirAll(filepath.Dir(lockPath), 0700); err != nil { + return nil, fmt.Errorf( + "cannot create lock directory [%s]: %w", + filepath.Dir(lockPath), + err, + ) + } + + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf( + "cannot open lock file [%s]: %w", + lockPath, + err, + ) + } + + if err := syscall.Flock( + int(lockFile.Fd()), + syscall.LOCK_EX|syscall.LOCK_NB, + ); err != nil { + lockFile.Close() + return nil, fmt.Errorf( + "cannot acquire exclusive lock on [%s]: "+ + "another process may already own the store: %w", + lockPath, + err, + ) + } + + return lockFile, nil +} + +// Close releases the exclusive file lock and closes the underlying lock file +// descriptor. For stores created without a dataDir (in-memory handles), Close +// is a safe no-op. Close is idempotent. +func (s *Store) Close() error { + if s.lockFile == nil { + return nil + } + + // Release the advisory lock before closing the file descriptor. + _ = syscall.Flock(int(s.lockFile.Fd()), syscall.LOCK_UN) + err := s.lockFile.Close() + s.lockFile = nil + + return err +} + +func routeKey(route TemplateID, routeRequestID string) string { + return fmt.Sprintf("%s:%s", route, routeRequestID) +} + +func cloneJob(job *Job) (*Job, error) { + payload, err := json.Marshal(job) + if err != nil { + return nil, err + } + + cloned := &Job{} + if err := json.Unmarshal(payload, cloned); err != nil { + return nil, err + } + + return cloned, nil +} + +func isNewerOrSameJobRevision(existing *Job, candidate *Job) (bool, error) { + existingUpdatedAt, err := time.Parse(time.RFC3339Nano, existing.UpdatedAt) + if err != nil { + return false, fmt.Errorf( + "cannot parse existing job updatedAt [%s] for request [%s]: %w", + existing.UpdatedAt, + existing.RequestID, + err, + ) + } + + candidateUpdatedAt, err := time.Parse(time.RFC3339Nano, candidate.UpdatedAt) + if err != nil { + return false, fmt.Errorf( + "cannot parse candidate job updatedAt [%s] for request [%s]: %w", + candidate.UpdatedAt, + candidate.RequestID, + err, + ) + } + + return !existingUpdatedAt.Before(candidateUpdatedAt), nil +} + +func (s *Store) load() error { + s.mutex.Lock() + defer s.mutex.Unlock() + + dataChan, errorChan := s.handle.ReadAll() + + for dataChan != nil || errorChan != nil { + select { + case descriptor, ok := <-dataChan: + if !ok { + dataChan = nil + continue + } + + if descriptor.Directory() != jobsDirectory { + continue + } + + content, err := descriptor.Content() + if err != nil { + return err + } + + job := &Job{} + if err := json.Unmarshal(content, job); err != nil { + return err + } + + existingID, ok := s.byRouteKey[routeKey(job.Route, job.RouteRequestID)] + if ok { + existing := s.byRequestID[existingID] + if existing != nil { + existingIsNewerOrSame, err := isNewerOrSameJobRevision(existing, job) + if err != nil { + return err + } + if existingIsNewerOrSame { + continue + } + } + } + + s.byRequestID[job.RequestID] = job + s.byRouteKey[routeKey(job.Route, job.RouteRequestID)] = job.RequestID + case err, ok := <-errorChan: + if !ok { + errorChan = nil + continue + } + if err != nil { + return err + } + } + } + + return nil +} + +func (s *Store) GetByRequestID(requestID string) (*Job, bool, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + job, ok := s.byRequestID[requestID] + if !ok { + return nil, false, nil + } + + cloned, err := cloneJob(job) + if err != nil { + return nil, false, err + } + + return cloned, true, nil +} + +func (s *Store) GetByRouteRequest(route TemplateID, routeRequestID string) (*Job, bool, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + requestID, ok := s.byRouteKey[routeKey(route, routeRequestID)] + if !ok { + return nil, false, nil + } + + job := s.byRequestID[requestID] + if job == nil { + return nil, false, nil + } + + cloned, err := cloneJob(job) + if err != nil { + return nil, false, err + } + + return cloned, true, nil +} + +func (s *Store) Put(job *Job) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + payload, err := json.Marshal(job) + if err != nil { + return err + } + + key := routeKey(job.Route, job.RouteRequestID) + existingRequestID, hasExisting := s.byRouteKey[key] + if err := s.handle.Save(payload, jobsDirectory, job.RequestID+".json"); err != nil { + return err + } + + cloned, err := cloneJob(job) + if err != nil { + return err + } + + s.byRequestID[job.RequestID] = cloned + s.byRouteKey[key] = job.RequestID + + if hasExisting && existingRequestID != job.RequestID { + if err := s.handle.Delete(jobsDirectory, existingRequestID+".json"); err != nil { + logger.Warnf( + "failed to delete stale covenant signer job file [%s]: [%v]", + existingRequestID+".json", + err, + ) + } else { + delete(s.byRequestID, existingRequestID) + } + } + + return nil +} diff --git a/pkg/covenantsigner/store_lock_test.go b/pkg/covenantsigner/store_lock_test.go new file mode 100644 index 0000000000..b763352c8b --- /dev/null +++ b/pkg/covenantsigner/store_lock_test.go @@ -0,0 +1,148 @@ +package covenantsigner + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/keep-network/keep-common/pkg/persistence" +) + +func TestNewStore_AcquiresFileLock(t *testing.T) { + tempDir := t.TempDir() + + handle, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store, err := NewStore(handle, tempDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + + lockPath := filepath.Join(tempDir, jobsDirectory, lockFileName) + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + t.Fatalf("expected lock file to exist at %s", lockPath) + } +} + +func TestNewStore_LockContention_ReturnsError(t *testing.T) { + tempDir := t.TempDir() + + handle1, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store1, err := NewStore(handle1, tempDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store1.Close() }) + + handle2, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store2, err := NewStore(handle2, tempDir) + if store2 != nil { + store2.Close() + t.Fatal("expected second store to fail, but it succeeded") + } + if err == nil { + t.Fatal("expected error when acquiring lock on already-locked directory") + } + if !strings.Contains(err.Error(), "lock") { + t.Fatalf("expected error message to contain 'lock', got: %v", err) + } +} + +func TestStore_Close_ReleasesLock(t *testing.T) { + tempDir := t.TempDir() + + handle1, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store1, err := NewStore(handle1, tempDir) + if err != nil { + t.Fatal(err) + } + + if err := store1.Close(); err != nil { + t.Fatalf("failed to close first store: %v", err) + } + + handle2, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store2, err := NewStore(handle2, tempDir) + if err != nil { + t.Fatalf("expected second store to succeed after first was closed, got: %v", err) + } + t.Cleanup(func() { store2.Close() }) +} + +func TestStore_Close_Idempotent(t *testing.T) { + tempDir := t.TempDir() + + handle, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store, err := NewStore(handle, tempDir) + if err != nil { + t.Fatal(err) + } + + if err := store.Close(); err != nil { + t.Fatalf("first close failed: %v", err) + } + + // Second close should not panic or return an error. + if err := store.Close(); err != nil { + t.Fatalf("second close should be a no-op, got: %v", err) + } +} + +func TestNewStore_InMemoryHandle_SkipsLock(t *testing.T) { + handle := newMemoryHandle() + + store, err := NewStore(handle, "") + if err != nil { + t.Fatal(err) + } + + // Close on a store without file locking should be a safe no-op. + if err := store.Close(); err != nil { + t.Fatalf("close on in-memory store should be a no-op, got: %v", err) + } +} + +func TestNewStore_SequentialOpenCloseOpen(t *testing.T) { + tempDir := t.TempDir() + + for i := 0; i < 3; i++ { + handle, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatalf("iteration %d: failed to create handle: %v", i, err) + } + + store, err := NewStore(handle, tempDir) + if err != nil { + t.Fatalf("iteration %d: failed to open store: %v", i, err) + } + + if err := store.Close(); err != nil { + t.Fatalf("iteration %d: failed to close store: %v", i, err) + } + } +} diff --git a/pkg/covenantsigner/store_test.go b/pkg/covenantsigner/store_test.go new file mode 100644 index 0000000000..9f6dc3cbad --- /dev/null +++ b/pkg/covenantsigner/store_test.go @@ -0,0 +1,262 @@ +package covenantsigner + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" +) + +func TestStoreReloadPreservesJobs(t *testing.T) { + handle := newMemoryHandle() + store, err := NewStore(handle, "") + if err != nil { + t.Fatal(err) + } + + job := &Job{ + RequestID: "kcs_self_1234", + RouteRequestID: "ors_reload", + Route: TemplateSelfV1, + IdempotencyKey: "idem_reload", + FacadeRequestID: "rf_reload", + RequestDigest: "0xdeadbeef", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + if err := store.Put(job); err != nil { + t.Fatal(err) + } + + reloaded, err := NewStore(handle, "") + if err != nil { + t.Fatal(err) + } + + loadedJob, ok, err := reloaded.GetByRouteRequest(TemplateSelfV1, "ors_reload") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected persisted job") + } + if !reflect.DeepEqual(job.Request, loadedJob.Request) { + t.Fatalf("unexpected reloaded request: %#v", loadedJob.Request) + } +} + +func TestStorePutReturnsErrorWhenSaveFails(t *testing.T) { + handle := newFaultingMemoryHandle() + handle.saveErrByName["kcs_self_fail_save.json"] = errors.New("injected save failure") + + store, err := NewStore(handle, "") + if err != nil { + t.Fatal(err) + } + + err = store.Put(&Job{ + RequestID: "kcs_self_fail_save", + RouteRequestID: "ors_fail_save", + Route: TemplateSelfV1, + IdempotencyKey: "idem_fail_save", + FacadeRequestID: "rf_fail_save", + RequestDigest: "0xdeadbeef", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + }) + if err == nil || !strings.Contains(err.Error(), "injected save failure") { + t.Fatalf("expected injected save failure, got: %v", err) + } + + _, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_fail_save") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected no route mapping after failed save") + } +} + +func TestStorePutKeepsNewRouteMappingWhenOldDeleteFails(t *testing.T) { + handle := newFaultingMemoryHandle() + store, err := NewStore(handle, "") + if err != nil { + t.Fatal(err) + } + + initial := &Job{ + RequestID: "kcs_self_old", + RouteRequestID: "ors_replace", + Route: TemplateSelfV1, + IdempotencyKey: "idem_old", + FacadeRequestID: "rf_old", + RequestDigest: "0x1111", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + if err := store.Put(initial); err != nil { + t.Fatal(err) + } + + handle.deleteErrByName["kcs_self_old.json"] = errors.New("injected delete failure") + + replacement := &Job{ + RequestID: "kcs_self_new", + RouteRequestID: "ors_replace", + Route: TemplateSelfV1, + IdempotencyKey: "idem_new", + FacadeRequestID: "rf_new", + RequestDigest: "0x2222", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-10T00:00:00Z", + UpdatedAt: "2026-03-10T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + if err := store.Put(replacement); err != nil { + t.Fatalf("expected replacement put to succeed, got: %v", err) + } + + loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_replace") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected route mapping to exist") + } + if loaded.RequestID != "kcs_self_new" { + t.Fatalf("expected route key to map to replacement job, got: %s", loaded.RequestID) + } +} + +func TestStoreLoadSelectsNewestJobForDuplicateRouteKeys(t *testing.T) { + handle := newMemoryHandle() + + oldJob := &Job{ + RequestID: "kcs_self_old_load", + RouteRequestID: "ors_load_dupe", + Route: TemplateSelfV1, + IdempotencyKey: "idem_old_load", + FacadeRequestID: "rf_old_load", + RequestDigest: "0xaaa", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + newJob := &Job{ + RequestID: "kcs_self_new_load", + RouteRequestID: "ors_load_dupe", + Route: TemplateSelfV1, + IdempotencyKey: "idem_new_load", + FacadeRequestID: "rf_new_load", + RequestDigest: "0xbbb", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-10T00:00:00Z", + UpdatedAt: "2026-03-10T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + oldPayload, err := json.Marshal(oldJob) + if err != nil { + t.Fatal(err) + } + newPayload, err := json.Marshal(newJob) + if err != nil { + t.Fatal(err) + } + + if err := handle.Save(oldPayload, jobsDirectory, oldJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + if err := handle.Save(newPayload, jobsDirectory, newJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + + store, err := NewStore(handle, "") + if err != nil { + t.Fatal(err) + } + + loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_load_dupe") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected loaded route mapping") + } + if loaded.RequestID != newJob.RequestID { + t.Fatalf("expected newest request ID %s, got %s", newJob.RequestID, loaded.RequestID) + } +} + +func TestStoreLoadFailsOnInvalidUpdatedAtForDuplicateRouteKeys(t *testing.T) { + handle := newMemoryHandle() + + first := &Job{ + RequestID: "kcs_self_first_load", + RouteRequestID: "ors_load_invalid_updated_at", + Route: TemplateSelfV1, + IdempotencyKey: "idem_first_load", + FacadeRequestID: "rf_first_load", + RequestDigest: "0xaaa", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + second := &Job{ + RequestID: "kcs_self_second_load", + RouteRequestID: "ors_load_invalid_updated_at", + Route: TemplateSelfV1, + IdempotencyKey: "idem_second_load", + FacadeRequestID: "rf_second_load", + RequestDigest: "0xbbb", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-10T00:00:00Z", + UpdatedAt: "invalid-timestamp", + Request: baseRequest(TemplateSelfV1), + } + + firstPayload, err := json.Marshal(first) + if err != nil { + t.Fatal(err) + } + secondPayload, err := json.Marshal(second) + if err != nil { + t.Fatal(err) + } + + if err := handle.Save(firstPayload, jobsDirectory, first.RequestID+".json"); err != nil { + t.Fatal(err) + } + if err := handle.Save(secondPayload, jobsDirectory, second.RequestID+".json"); err != nil { + t.Fatal(err) + } + + _, err = NewStore(handle, "") + if err == nil { + t.Fatal("expected invalid UpdatedAt error") + } + if !strings.Contains(err.Error(), "cannot parse candidate job updatedAt") && + !strings.Contains(err.Error(), "cannot parse existing job updatedAt") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json new file mode 100644 index 0000000000..db61ffba15 --- /dev/null +++ b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json @@ -0,0 +1,268 @@ +{ + "version": 1, + "scope": "covenant_recovery_approval_contract_v1", + "vectors": { + "qc_v1": { + "expectedApprovalDigest": "0xa6ffb42318a8e8b3b9669324ee5ad393133afcc9cc81044739cbaa77d5fa34c9", + "canonicalSubmitRequest": { + "facadeRequestId": "rf_vector_qc_v1", + "idempotencyKey": "idem-qc-vector-v1", + "route": "qc_v1", + "requestType": "reconstruct", + "strategy": "0x1111111111111111111111111111111111111111", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "maturityHeight": 950000, + "activeOutpoint": { + "txid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "vout": 1, + "scriptHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "migrationDestination": { + "reservationId": "cmdr_12345678", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "route": "MIGRATION", + "revealer": "0x2222222222222222222222222222222222222222", + "vault": "0x3333333333333333333333333333333333333333", + "network": "regtest", + "status": "RESERVED", + "depositScript": "0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "depositScriptHash": "0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", + "migrationExtraData": "0x41435f4d49475241544556312222222222222222222222222222222222222222", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9" + }, + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969", + "inputValueSats": 1000000, + "destinationValueSats": 998000, + "anchorValueSats": 330, + "feeSats": 1670, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "artifactApprovals": { + "payload": { + "approvalVersion": 1, + "route": "qc_v1", + "scriptTemplateId": "qc_v1", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969" + }, + "approvals": [ + { + "role": "D", + "signature": "0xd0d0" + }, + { + "role": "C", + "signature": "0xc0c0" + } + ] + }, + "signerApproval": { + "certificateVersion": 1, + "signatureAlgorithm": "tecdsa-secp256k1", + "approvalDigest": "0xa6ffb42318a8e8b3b9669324ee5ad393133afcc9cc81044739cbaa77d5fa34c9", + "walletPublicKey": "0x04d140d1eedb94f53ce43e0f4d68e8e0de6d6f2a444ef98f2a0e6c0f7fca02ef7dc4cb14e7b0f7c23787c93ca4d978f312c64379f38d9f52f86d1a89f0f8572f9f", + "signerSetHash": "0xabababababababababababababababababababababababababababababababab", + "signature": "0x5050", + "activeMembers": [1, 2, 3], + "inactiveMembers": [4, 5], + "endBlock": 123 + }, + "artifactSignatures": [ + "0xd0d0", + "0xc0c0", + "0x5050" + ], + "artifacts": {}, + "scriptTemplate": { + "template": "qc_v1", + "depositorPublicKey": "0x02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "custodianPublicKey": "0x02dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "signerPublicKey": "0x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "beta": 144, + "delta2": 4320 + }, + "signing": { + "signerRequired": true, + "custodianRequired": true + } + }, + "expectedRequestDigest": "0x8538a608ffbc3264655f9d87e334bfb7fa0d46e37cb6ffdb7c98b803eec900c8" + }, + "self_v1": { + "expectedApprovalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", + "canonicalSubmitRequest": { + "facadeRequestId": "rf_vector_self_v1", + "idempotencyKey": "idem-self-vector-v1", + "route": "self_v1", + "requestType": "reconstruct", + "strategy": "0x1111111111111111111111111111111111111111", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "maturityHeight": 950000, + "activeOutpoint": { + "txid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "vout": 1, + "scriptHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "migrationDestination": { + "reservationId": "cmdr_12345678", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "route": "MIGRATION", + "revealer": "0x2222222222222222222222222222222222222222", + "vault": "0x3333333333333333333333333333333333333333", + "network": "regtest", + "status": "RESERVED", + "depositScript": "0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "depositScriptHash": "0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", + "migrationExtraData": "0x41435f4d49475241544556312222222222222222222222222222222222222222", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9" + }, + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969", + "inputValueSats": 1000000, + "destinationValueSats": 998000, + "anchorValueSats": 330, + "feeSats": 1670, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "artifactApprovals": { + "payload": { + "approvalVersion": 1, + "route": "self_v1", + "scriptTemplateId": "self_v1", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969" + }, + "approvals": [ + { + "role": "D", + "signature": "0xd0d0" + } + ] + }, + "signerApproval": { + "certificateVersion": 1, + "signatureAlgorithm": "tecdsa-secp256k1", + "approvalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", + "walletPublicKey": "0x04d140d1eedb94f53ce43e0f4d68e8e0de6d6f2a444ef98f2a0e6c0f7fca02ef7dc4cb14e7b0f7c23787c93ca4d978f312c64379f38d9f52f86d1a89f0f8572f9f", + "signerSetHash": "0xabababababababababababababababababababababababababababababababab", + "signature": "0x5050", + "activeMembers": [1, 2, 3], + "inactiveMembers": [4, 5], + "endBlock": 123 + }, + "artifactSignatures": [ + "0xd0d0", + "0x5050" + ], + "artifacts": {}, + "scriptTemplate": { + "template": "self_v1", + "depositorPublicKey": "0x02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "signerPublicKey": "0x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "delta2": 4320 + }, + "signing": { + "signerRequired": true, + "custodianRequired": false + } + }, + "expectedRequestDigest": "0x2da9d108af3d175865ee0654843a2c61eaf7fcbcf5d48afd807044725f310d17" + }, + "self_v1_presign": { + "expectedApprovalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", + "canonicalSubmitRequest": { + "facadeRequestId": "rf_vector_self_v1_presign", + "idempotencyKey": "idem-self-presign-vector-v1", + "route": "self_v1", + "requestType": "presign_self_v1", + "strategy": "0x1111111111111111111111111111111111111111", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "maturityHeight": 950000, + "activeOutpoint": { + "txid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "vout": 1, + "scriptHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "migrationDestination": { + "reservationId": "cmdr_12345678", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "route": "MIGRATION", + "revealer": "0x2222222222222222222222222222222222222222", + "vault": "0x3333333333333333333333333333333333333333", + "network": "regtest", + "status": "RESERVED", + "depositScript": "0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "depositScriptHash": "0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", + "migrationExtraData": "0x41435f4d49475241544556312222222222222222222222222222222222222222", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9" + }, + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969", + "inputValueSats": 1000000, + "destinationValueSats": 998000, + "anchorValueSats": 330, + "feeSats": 1670, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "artifactApprovals": { + "payload": { + "approvalVersion": 1, + "route": "self_v1", + "scriptTemplateId": "self_v1", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969" + }, + "approvals": [ + { + "role": "D", + "signature": "0xd0d0" + } + ] + }, + "signerApproval": { + "certificateVersion": 1, + "signatureAlgorithm": "tecdsa-secp256k1", + "approvalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", + "walletPublicKey": "0x04d140d1eedb94f53ce43e0f4d68e8e0de6d6f2a444ef98f2a0e6c0f7fca02ef7dc4cb14e7b0f7c23787c93ca4d978f312c64379f38d9f52f86d1a89f0f8572f9f", + "signerSetHash": "0xabababababababababababababababababababababababababababababababab", + "signature": "0x5050", + "activeMembers": [1, 2, 3], + "inactiveMembers": [4, 5], + "endBlock": 123 + }, + "artifactSignatures": [ + "0xd0d0", + "0x5050" + ], + "artifacts": {}, + "scriptTemplate": { + "template": "self_v1", + "depositorPublicKey": "0x02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "signerPublicKey": "0x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "delta2": 4320 + }, + "signing": { + "signerRequired": true, + "custodianRequired": false + } + }, + "expectedRequestDigest": "0x4399f8651fea31ab227bffd3db96daa969612e9e5195394df3f349af4713cf03" + } + } +} diff --git a/pkg/covenantsigner/testdata/migration_plan_quote_signing_vectors_v1.json b/pkg/covenantsigner/testdata/migration_plan_quote_signing_vectors_v1.json new file mode 100644 index 0000000000..3a917dae5f --- /dev/null +++ b/pkg/covenantsigner/testdata/migration_plan_quote_signing_vectors_v1.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "scope": "migration_plan_quote_signing_contract_v1", + "trustRoot": { + "keyId": "test-plan-quote-key", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAbp6B4Eys+80lvGkOsR8p2QQPadm+ocqA4/V7bhQBHBc=\n-----END PUBLIC KEY-----\n" + }, + "vectors": { + "base": { + "unsignedQuote": { + "quoteId": "cmdq_testvector", + "quoteVersion": 1, + "reservationId": "cmdr_testvector", + "reserve": "0x1111111111111111111111111111111111111111", + "epoch": 7, + "route": "MIGRATION", + "revealer": "0x2222222222222222222222222222222222222222", + "vault": "0x3333333333333333333333333333333333333333", + "network": "regtest", + "destinationCommitmentHash": "0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7", + "activeOutpointTxid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "activeOutpointVout": 1, + "planCommitmentHash": "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + "inputValueSats": 100000, + "destinationValueSats": 99250, + "anchorValueSats": 330, + "feeSats": 420, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "idempotencyKey": "0x75a998ac6951c2776f3a85f6430fb41321c28c1113a71a52c754806c7a3de9c9", + "expiresInSeconds": 900, + "issuedAt": "2026-03-09T00:00:00.000Z", + "expiresAt": "2026-03-09T00:15:00.000Z" + }, + "expectedPayload": "{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0x1111111111111111111111111111111111111111\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0x2222222222222222222222222222222222222222\",\"vault\":\"0x3333333333333333333333333333333333333333\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}", + "expectedPreimage": "migration-plan-quote-v1:{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0x1111111111111111111111111111111111111111\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0x2222222222222222222222222222222222222222\",\"vault\":\"0x3333333333333333333333333333333333333333\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}", + "expectedHash": "0x4707935286fa15edf3f95485297307734b122f7dc1761e6fc023e9d5cc7a935a", + "expectedSignature": "0xaae307e9daa2f42f718e8a247c59002a3af7c63f7dd3c67aaa7643d470e787315a5e8f7e330d41c311def8dbf9892bee7d4b86992b81d62a3c194b68c3f0cd03" + }, + "mixed_case": { + "unsignedQuote": { + "quoteId": "cmdq_testvector", + "quoteVersion": 1, + "reservationId": "cmdr_testvector", + "reserve": "0xAaBbCcDdEeFf00112233445566778899AaBbCcDd", + "epoch": 7, + "route": "MIGRATION", + "revealer": "0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd", + "vault": "0x0011AaBbCcDdEeFf0011AaBbCcDdEeFf0011AaBb", + "network": "regtest", + "destinationCommitmentHash": "0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7", + "activeOutpointTxid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "activeOutpointVout": 1, + "planCommitmentHash": "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + "inputValueSats": 100000, + "destinationValueSats": 99250, + "anchorValueSats": 330, + "feeSats": 420, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "idempotencyKey": "0x75a998ac6951c2776f3a85f6430fb41321c28c1113a71a52c754806c7a3de9c9", + "expiresInSeconds": 900, + "issuedAt": "2026-03-09T00:00:00.000Z", + "expiresAt": "2026-03-09T00:15:00.000Z" + }, + "expectedPayload": "{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0xaabbccddeeff00112233445566778899aabbccdd\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0xabcdefabcdefabcdefabcdefabcdefabcdefabcd\",\"vault\":\"0x0011aabbccddeeff0011aabbccddeeff0011aabb\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}", + "expectedPreimage": "migration-plan-quote-v1:{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0xaabbccddeeff00112233445566778899aabbccdd\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0xabcdefabcdefabcdefabcdefabcdefabcdefabcd\",\"vault\":\"0x0011aabbccddeeff0011aabbccddeeff0011aabb\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}", + "expectedHash": "0x13a05f7e9caa244c446b65c2812095210cb321451d9eb9b735e60ffdd76e693d", + "expectedSignature": "0x99ece768accfd8ae222ae2ecba80585fc3664cd84277e53248a4d5d37c48961e20547d66d8acc511ada8b5c0a76d2e22477402f6649d6e2193165f078f6cfe02" + } + } +} diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go new file mode 100644 index 0000000000..b991bbb7e9 --- /dev/null +++ b/pkg/covenantsigner/types.go @@ -0,0 +1,291 @@ +package covenantsigner + +import "encoding/json" + +type TemplateID string + +const ( + TemplateQcV1 TemplateID = "qc_v1" + TemplateSelfV1 TemplateID = "self_v1" +) + +type RequestType string + +const ( + RequestTypeReconstruct RequestType = "reconstruct" + RequestTypePresignSelfV1 RequestType = "presign_self_v1" +) + +type RecoveryPathID string + +const ( + PathCooperative RecoveryPathID = "COOPERATIVE" + PathMigration RecoveryPathID = "MIGRATION" + PathEarlyExit RecoveryPathID = "EARLY_EXIT" + PathLastResort RecoveryPathID = "LAST_RESORT" +) + +type RecoveryStage string + +const ( + StageSignerCoordination RecoveryStage = "SIGNER_COORDINATION" +) + +type FailureReason string + +const ( + ReasonAuthFailed FailureReason = "AUTH_FAILED" + ReasonPolicyRejected FailureReason = "POLICY_REJECTED" + ReasonInvalidInput FailureReason = "INVALID_INPUT" + ReasonProviderUnavailable FailureReason = "PROVIDER_UNAVAILABLE" + ReasonJobNotFound FailureReason = "JOB_NOT_FOUND" + ReasonJobPending FailureReason = "JOB_PENDING" + ReasonProviderFailed FailureReason = "PROVIDER_FAILED" + ReasonMalformedArtifact FailureReason = "MALFORMED_ARTIFACT" +) + +type ReservationRoute string + +const ( + ReservationRouteMigration ReservationRoute = "MIGRATION" +) + +type ReservationStatus string + +const ( + ReservationStatusReserved ReservationStatus = "RESERVED" + ReservationStatusCommittedToEpoch ReservationStatus = "COMMITTED_TO_EPOCH" + ReservationStatusRevealed ReservationStatus = "REVEALED" + ReservationStatusRetired ReservationStatus = "RETIRED" + ReservationStatusExpired ReservationStatus = "EXPIRED" +) + +type StepStatus string + +const ( + StepStatusPending StepStatus = "PENDING" + StepStatusReady StepStatus = "READY" + StepStatusFailed StepStatus = "FAILED" +) + +type JobState string + +const ( + JobStateSubmitted JobState = "SUBMITTED" + JobStateValidating JobState = "VALIDATING" + JobStateSigning JobState = "SIGNING" + JobStatePending JobState = "PENDING" + JobStateArtifactReady JobState = "ARTIFACT_READY" + JobStateHandoffReady JobState = "HANDOFF_READY" + JobStateFailed JobState = "FAILED" +) + +type CovenantOutpoint struct { + TxID string `json:"txid"` + Vout uint32 `json:"vout"` + ScriptHash string `json:"scriptHash,omitempty"` +} + +type ArtifactRecord struct { + PSBTHash string `json:"psbtHash"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + TransactionHex string `json:"transactionHex,omitempty"` + TransactionID string `json:"transactionId,omitempty"` +} + +type MigrationDestinationReservation struct { + ReservationID string `json:"reservationId,omitempty"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route ReservationRoute `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + Status ReservationStatus `json:"status"` + DepositScript string `json:"depositScript"` + DepositScriptHash string `json:"depositScriptHash"` + MigrationExtraData string `json:"migrationExtraData"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` +} + +type MigrationTransactionPlan struct { + PlanVersion uint32 `json:"planVersion"` + PlanCommitmentHash string `json:"planCommitmentHash"` + InputValueSats uint64 `json:"inputValueSats"` + DestinationValueSats uint64 `json:"destinationValueSats"` + AnchorValueSats uint64 `json:"anchorValueSats"` + FeeSats uint64 `json:"feeSats"` + InputSequence uint32 `json:"inputSequence"` + LockTime uint32 `json:"lockTime"` +} + +type MigrationDestinationPlanQuoteSignature struct { + SignatureVersion uint32 `json:"signatureVersion"` + Algorithm string `json:"algorithm"` + KeyID string `json:"keyId"` + Signature string `json:"signature"` +} + +type MigrationDestinationPlanQuote struct { + QuoteID string `json:"quoteId"` + QuoteVersion uint32 `json:"quoteVersion"` + ReservationID string `json:"reservationId"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route ReservationRoute `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + PlanCommitmentHash string `json:"planCommitmentHash"` + MigrationTransactionPlan *MigrationTransactionPlan `json:"migrationTransactionPlan"` + IdempotencyKey string `json:"idempotencyKey"` + ExpiresInSeconds uint64 `json:"expiresInSeconds"` + IssuedAt string `json:"issuedAt"` + ExpiresAt string `json:"expiresAt"` + Signature MigrationDestinationPlanQuoteSignature `json:"signature"` +} + +type MigrationPlanQuoteTrustRoot struct { + KeyID string `json:"keyId" mapstructure:"keyId"` + PublicKeyPEM string `json:"publicKeyPem" mapstructure:"publicKeyPem"` +} + +type DepositorTrustRoot struct { + Route TemplateID `json:"route" mapstructure:"route"` + Reserve string `json:"reserve" mapstructure:"reserve"` + Network string `json:"network" mapstructure:"network"` + PublicKey string `json:"publicKey" mapstructure:"publicKey"` +} + +type CustodianTrustRoot struct { + Route TemplateID `json:"route" mapstructure:"route"` + Reserve string `json:"reserve" mapstructure:"reserve"` + Network string `json:"network" mapstructure:"network"` + PublicKey string `json:"publicKey" mapstructure:"publicKey"` +} + +type ArtifactApprovalRole string + +const ( + ArtifactApprovalRoleDepositor ArtifactApprovalRole = "D" + ArtifactApprovalRoleCustodian ArtifactApprovalRole = "C" +) + +type ArtifactApprovalPayload struct { + ApprovalVersion uint32 `json:"approvalVersion"` + Route TemplateID `json:"route"` + ScriptTemplateID TemplateID `json:"scriptTemplateId"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + PlanCommitmentHash string `json:"planCommitmentHash"` +} + +type ArtifactRoleApproval struct { + Role ArtifactApprovalRole `json:"role"` + Signature string `json:"signature"` +} + +type ArtifactApprovalEnvelope struct { + Payload ArtifactApprovalPayload `json:"payload"` + Approvals []ArtifactRoleApproval `json:"approvals"` +} + +type SignerApprovalCertificate struct { + CertificateVersion uint32 `json:"certificateVersion"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + ApprovalDigest string `json:"approvalDigest"` + WalletPublicKey string `json:"walletPublicKey"` + SignerSetHash string `json:"signerSetHash"` + Signature string `json:"signature"` + ActiveMembers []uint32 `json:"activeMembers,omitempty"` + InactiveMembers []uint32 `json:"inactiveMembers,omitempty"` + EndBlock *uint64 `json:"endBlock,omitempty"` +} + +type SigningRequirements struct { + SignerRequired bool `json:"signerRequired"` + CustodianRequired bool `json:"custodianRequired"` +} + +type RouteSubmitRequest struct { + FacadeRequestID string `json:"facadeRequestId"` + IdempotencyKey string `json:"idempotencyKey"` + RequestType RequestType `json:"requestType"` + Route TemplateID `json:"route"` + Strategy string `json:"strategy"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + MaturityHeight uint64 `json:"maturityHeight"` + ActiveOutpoint CovenantOutpoint `json:"activeOutpoint"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + MigrationDestination *MigrationDestinationReservation `json:"migrationDestination,omitempty"` + MigrationPlanQuote *MigrationDestinationPlanQuote `json:"migrationPlanQuote,omitempty"` + MigrationTransactionPlan *MigrationTransactionPlan `json:"migrationTransactionPlan,omitempty"` + ArtifactApprovals *ArtifactApprovalEnvelope `json:"artifactApprovals,omitempty"` + SignerApproval *SignerApprovalCertificate `json:"signerApproval,omitempty"` + ArtifactSignatures []string `json:"artifactSignatures"` + Artifacts map[RecoveryPathID]ArtifactRecord `json:"artifacts"` + ScriptTemplate json.RawMessage `json:"scriptTemplate"` + Signing SigningRequirements `json:"signing"` +} + +type SignerSubmitInput struct { + RouteRequestID string `json:"routeRequestId"` + Request RouteSubmitRequest `json:"request"` + Stage RecoveryStage `json:"stage"` +} + +type SignerPollInput struct { + RouteRequestID string `json:"routeRequestId"` + RequestID string `json:"requestId"` + Request RouteSubmitRequest `json:"request"` + Stage RecoveryStage `json:"stage"` +} + +type StepResult struct { + Status StepStatus `json:"status"` + RequestID string `json:"requestId,omitempty"` + Detail string `json:"detail,omitempty"` + Reason FailureReason `json:"reason,omitempty"` + PSBTHash string `json:"psbtHash,omitempty"` + TransactionHex string `json:"transactionHex,omitempty"` + Handoff map[string]any `json:"handoff,omitempty"` +} + +type Job struct { + RequestID string `json:"requestId"` + RouteRequestID string `json:"routeRequestId"` + Route TemplateID `json:"route"` + IdempotencyKey string `json:"idempotencyKey"` + FacadeRequestID string `json:"facadeRequestId"` + RequestDigest string `json:"requestDigest"` + State JobState `json:"state"` + Detail string `json:"detail,omitempty"` + Reason FailureReason `json:"reason,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CompletedAt string `json:"completedAt,omitempty"` + FailedAt string `json:"failedAt,omitempty"` + Request RouteSubmitRequest `json:"request"` + PSBTHash string `json:"psbtHash,omitempty"` + TransactionHex string `json:"transactionHex,omitempty"` + Handoff map[string]any `json:"handoff,omitempty"` +} + +type SelfV1Template struct { + Template TemplateID `json:"template"` + DepositorPublicKey string `json:"depositorPublicKey"` + SignerPublicKey string `json:"signerPublicKey"` + Delta2 uint64 `json:"delta2"` +} + +type QcV1Template struct { + Template TemplateID `json:"template"` + DepositorPublicKey string `json:"depositorPublicKey"` + CustodianPublicKey string `json:"custodianPublicKey"` + SignerPublicKey string `json:"signerPublicKey"` + Beta uint64 `json:"beta"` + Delta2 uint64 `json:"delta2"` +} diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go new file mode 100644 index 0000000000..11e1c653e7 --- /dev/null +++ b/pkg/covenantsigner/validation.go @@ -0,0 +1,1922 @@ +package covenantsigner + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/binary" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "math" + "math/big" + "reflect" + "regexp" + "sort" + "strings" + "time" + + "github.com/btcsuite/btcd/btcec" + "github.com/ethereum/go-ethereum/crypto" + "github.com/keep-network/keep-core/pkg/internal/canonicaljson" +) + +const ( + canonicalCovenantInputSequence uint32 = 0xFFFFFFFD + canonicalAnchorValueSats uint64 = 330 + migrationTransactionPlanVersion uint32 = 1 + artifactApprovalVersion uint32 = 1 + signerApprovalCertificateVersion uint32 = 1 + migrationPlanQuoteVersion uint32 = 1 + migrationPlanQuoteSignatureVersion uint32 = 1 +) + +const ( + migrationPlanQuoteSignatureAlgorithm = "ed25519" + migrationPlanQuoteSigningDomain = "migration-plan-quote-v1:" + signerApprovalSignatureAlgorithm = "tecdsa-secp256k1" + covenantSignerRequestDigestDomain = "covenant-signer-request-v1:" +) + +var artifactApprovalTypeHash = crypto.Keccak256Hash([]byte( + "ArtifactApproval(" + + "uint8 approvalVersion," + + "bytes32 route," + + "bytes32 scriptTemplateId," + + "bytes32 destinationCommitmentHash," + + "bytes32 planCommitmentHash)", +)) + +var canonicalTimestampPattern = regexp.MustCompile( + `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`, +) + +var requestIdentifierPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,255}$`) + +type inputError struct { + message string +} + +func (ie *inputError) Error() string { + return ie.message +} + +func NewInputError(message string) error { + return &inputError{message: message} +} + +func strictUnmarshal(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +type validationOptions struct { + migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot + depositorTrustRoots []DepositorTrustRoot + custodianTrustRoots []CustodianTrustRoot + requireFreshMigrationPlanQuote bool + migrationPlanQuoteVerificationNow time.Time + signerApprovalVerifier SignerApprovalVerifier + policyIndependentDigest bool +} + +// requestDigest accepts raw requests because Poll validates equivalence against +// whatever the caller resubmits. Submit should use requestDigestFromNormalized +// after it has already normalized the request once for storage. +func requestDigest( + request RouteSubmitRequest, + options validationOptions, +) (string, error) { + normalizedRequest, err := normalizeRouteSubmitRequest( + request, + options, + ) + if err != nil { + return "", err + } + + return requestDigestFromNormalized(normalizedRequest) +} + +// requestDigestFromNormalized computes a domain-separated SHA256 digest of +// the canonical JSON encoding of the already-normalized request. The domain +// prefix prevents cross-context hash collisions with other SHA256-based +// identifiers in the protocol. +func requestDigestFromNormalized(request RouteSubmitRequest) (string, error) { + payload, err := canonicaljson.Marshal(request) + if err != nil { + return "", err + } + + sum := sha256.Sum256(append([]byte(covenantSignerRequestDigestDomain), payload...)) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func validateHexString(name string, value string) error { + if !strings.HasPrefix(value, "0x") || len(value) <= 2 || len(value)%2 != 0 { + return &inputError{fmt.Sprintf("%s must be a 0x-prefixed even-length hex string", name)} + } + + if _, err := hex.DecodeString(strings.TrimPrefix(value, "0x")); err != nil { + return &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + return nil +} + +func validateRequestIdentifier(name string, value string) error { + if !requestIdentifierPattern.MatchString(value) { + return &inputError{fmt.Sprintf("%s must match [a-zA-Z0-9_-] and be at most 255 chars", name)} + } + + return nil +} + +func validateAddressString(name string, value string) error { + if err := validateHexString(name, value); err != nil { + return err + } + + if len(value) != 42 { + return &inputError{fmt.Sprintf("%s must be a 20-byte 0x-prefixed hex address", name)} + } + + return nil +} + +func validateBytes32HexString(name string, value string) error { + if err := validateHexString(name, value); err != nil { + return err + } + + if len(value) != 66 { + return &inputError{fmt.Sprintf("%s must be a 32-byte 0x-prefixed hex string", name)} + } + + return nil +} + +func validateUint32Range(name string, value uint64) error { + if value > math.MaxUint32 { + return &inputError{fmt.Sprintf("%s must fit in uint32", name)} + } + + return nil +} + +func decodeBytes32HexString(name string, value string) ([32]byte, error) { + var decoded [32]byte + + if err := validateBytes32HexString(name, value); err != nil { + return decoded, err + } + + rawValue, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + return decoded, &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + copy(decoded[:], rawValue) + return decoded, nil +} + +func normalizeSignerApprovalMemberIndexes( + name string, + values []uint32, +) ([]uint32, error) { + if len(values) == 0 { + return nil, nil + } + + normalized := append([]uint32{}, values...) + seen := make(map[uint32]struct{}, len(normalized)) + for i, value := range normalized { + if value == 0 { + return nil, &inputError{ + fmt.Sprintf("%s[%d] must be greater than zero", name, i), + } + } + if err := validateUint32Range(name, uint64(value)); err != nil { + return nil, err + } + if _, ok := seen[value]; ok { + return nil, &inputError{ + fmt.Sprintf("%s[%d] duplicates member %d", name, i, value), + } + } + seen[value] = struct{}{} + } + + sort.Slice(normalized, func(i, j int) bool { + return normalized[i] < normalized[j] + }) + + return normalized, nil +} + +func normalizeRequestType( + route TemplateID, + requestType RequestType, +) (RequestType, error) { + switch requestType { + case RequestTypeReconstruct: + return requestType, nil + case RequestTypePresignSelfV1: + if route != TemplateSelfV1 { + return "", &inputError{"request.requestType must be reconstruct for qc_v1"} + } + return requestType, nil + default: + return "", &inputError{"request.requestType must be reconstruct or presign_self_v1"} + } +} + +func normalizeSignerApprovalCertificate( + request RouteSubmitRequest, +) (*SignerApprovalCertificate, error) { + if request.SignerApproval == nil { + return nil, nil + } + if request.ArtifactApprovals == nil { + return nil, &inputError{ + "request.artifactApprovals is required when request.signerApproval is present", + } + } + + signerApproval := request.SignerApproval + if signerApproval.CertificateVersion != signerApprovalCertificateVersion { + return nil, &inputError{ + fmt.Sprintf( + "request.signerApproval.certificateVersion must equal %d", + signerApprovalCertificateVersion, + ), + } + } + if signerApproval.SignatureAlgorithm != signerApprovalSignatureAlgorithm { + return nil, &inputError{ + fmt.Sprintf( + "request.signerApproval.signatureAlgorithm must equal %s", + signerApprovalSignatureAlgorithm, + ), + } + } + if err := validateBytes32HexString( + "request.signerApproval.approvalDigest", + signerApproval.ApprovalDigest, + ); err != nil { + return nil, err + } + if err := validateHexString( + "request.signerApproval.walletPublicKey", + signerApproval.WalletPublicKey, + ); err != nil { + return nil, err + } + if len(signerApproval.WalletPublicKey) != 132 { + // This must match tbtc marshalPublicKey/unmarshalPublicKey: + // uncompressed SEC1 public key (0x04 + 64-byte coordinates). + return nil, &inputError{ + "request.signerApproval.walletPublicKey must be a 65-byte uncompressed secp256k1 public key", + } + } + normalizedWalletPublicKey := normalizeLowerHex(signerApproval.WalletPublicKey) + if !strings.HasPrefix(normalizedWalletPublicKey, "0x04") { + return nil, &inputError{ + "request.signerApproval.walletPublicKey must be a 65-byte uncompressed secp256k1 public key", + } + } + if err := validateBytes32HexString( + "request.signerApproval.signerSetHash", + signerApproval.SignerSetHash, + ); err != nil { + return nil, err + } + if err := validateHexString( + "request.signerApproval.signature", + signerApproval.Signature, + ); err != nil { + return nil, err + } + + expectedApprovalDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + return nil, err + } + + normalizedApprovalDigest := normalizeLowerHex(signerApproval.ApprovalDigest) + if normalizedApprovalDigest != "0x"+hex.EncodeToString(expectedApprovalDigest) { + return nil, &inputError{ + "request.signerApproval.approvalDigest must match the canonical artifactApprovals payload digest", + } + } + + normalizedSignerApproval := &SignerApprovalCertificate{ + CertificateVersion: signerApprovalCertificateVersion, + SignatureAlgorithm: signerApprovalSignatureAlgorithm, + ApprovalDigest: normalizedApprovalDigest, + WalletPublicKey: normalizedWalletPublicKey, + SignerSetHash: normalizeLowerHex(signerApproval.SignerSetHash), + Signature: normalizeLowerHex(signerApproval.Signature), + } + + activeMembers, err := normalizeSignerApprovalMemberIndexes( + "request.signerApproval.activeMembers", + signerApproval.ActiveMembers, + ) + if err != nil { + return nil, err + } + if len(activeMembers) > 0 { + normalizedSignerApproval.ActiveMembers = activeMembers + } + + inactiveMembers, err := normalizeSignerApprovalMemberIndexes( + "request.signerApproval.inactiveMembers", + signerApproval.InactiveMembers, + ) + if err != nil { + return nil, err + } + if len(inactiveMembers) > 0 { + normalizedSignerApproval.InactiveMembers = inactiveMembers + } + + if len(activeMembers) > 0 && len(inactiveMembers) > 0 { + activeSet := make(map[uint32]struct{}, len(activeMembers)) + for _, value := range activeMembers { + activeSet[value] = struct{}{} + } + for _, value := range inactiveMembers { + if _, ok := activeSet[value]; ok { + return nil, &inputError{ + "request.signerApproval.activeMembers and request.signerApproval.inactiveMembers must not overlap", + } + } + } + } + + if signerApproval.EndBlock != nil { + if err := validateUint32Range( + "request.signerApproval.endBlock", + *signerApproval.EndBlock, + ); err != nil { + return nil, err + } + endBlock := *signerApproval.EndBlock + normalizedSignerApproval.EndBlock = &endBlock + } + + return normalizedSignerApproval, nil +} + +func normalizeLowerHex(value string) string { + return strings.ToLower(value) +} + +func abiEncodeUint32Word(value uint32) [32]byte { + var encoded [32]byte + binary.BigEndian.PutUint32(encoded[28:], value) + return encoded +} + +func keccakTemplateIdentifier(id TemplateID) [32]byte { + hash := crypto.Keccak256Hash([]byte(id)) + + var encoded [32]byte + copy(encoded[:], hash.Bytes()) + + return encoded +} + +// artifactApprovalDigest pins the current phase-1 approval payload contract to +// a deterministic EIP-712-compatible struct hash, without yet committing to a +// chain-specific domain separator. +func artifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { + destinationCommitmentHash, err := decodeBytes32HexString( + "request.artifactApprovals.payload.destinationCommitmentHash", + payload.DestinationCommitmentHash, + ) + if err != nil { + return nil, err + } + + planCommitmentHash, err := decodeBytes32HexString( + "request.artifactApprovals.payload.planCommitmentHash", + payload.PlanCommitmentHash, + ) + if err != nil { + return nil, err + } + + encoded := make([]byte, 32*6) + approvalVersionWord := abiEncodeUint32Word(payload.ApprovalVersion) + routeIdentifier := keccakTemplateIdentifier(payload.Route) + scriptTemplateIdentifier := keccakTemplateIdentifier(payload.ScriptTemplateID) + + copy(encoded[0:32], artifactApprovalTypeHash.Bytes()) + copy(encoded[32:64], approvalVersionWord[:]) + copy(encoded[64:96], routeIdentifier[:]) + copy(encoded[96:128], scriptTemplateIdentifier[:]) + copy(encoded[128:160], destinationCommitmentHash[:]) + copy(encoded[160:192], planCommitmentHash[:]) + + digest := crypto.Keccak256Hash(encoded) + return digest.Bytes(), nil +} + +// ComputeArtifactApprovalDigest exposes the current phase-1 approval payload +// digest contract to cross-package verifiers that need to bind +// signerApproval.approvalDigest to request.artifactApprovals.payload. +func ComputeArtifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { + return artifactApprovalDigest(payload) +} + +func parseCompressedSecp256k1PublicKey( + name string, + value string, +) (*btcec.PublicKey, error) { + if err := validateHexString(name, value); err != nil { + return nil, err + } + + rawValue, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + return nil, &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + if len(rawValue) != 33 || (rawValue[0] != 0x02 && rawValue[0] != 0x03) { + return nil, &inputError{fmt.Sprintf("%s must be a compressed secp256k1 public key", name)} + } + + publicKey, err := btcec.ParsePubKey(rawValue, btcec.S256()) + if err != nil { + return nil, &inputError{fmt.Sprintf("%s must be a compressed secp256k1 public key", name)} + } + + return publicKey, nil +} + +func verifyCompactSecp256k1Signature( + publicKey *btcec.PublicKey, + digest []byte, + signature []byte, +) bool { + return ecdsa.Verify( + publicKey.ToECDSA(), + digest, + new(big.Int).SetBytes(signature[:32]), + new(big.Int).SetBytes(signature[32:]), + ) +} + +func isLowSSecp256k1(s *big.Int) bool { + halfOrder := new(big.Int).Rsh(new(big.Int).Set(btcec.S256().N), 1) + return s.Cmp(halfOrder) <= 0 +} + +func verifySecp256k1Signature( + name string, + publicKey *btcec.PublicKey, + digest []byte, + signature string, +) error { + rawSignature, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) + if err != nil { + return &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + switch { + case len(rawSignature) == 64: + if !isLowSSecp256k1(new(big.Int).SetBytes(rawSignature[32:])) { + return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} + } + if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature) { + return nil + } + case len(rawSignature) == 65 && + (rawSignature[64] == 0 || rawSignature[64] == 1 || rawSignature[64] == 27 || rawSignature[64] == 28): + if !isLowSSecp256k1(new(big.Int).SetBytes(rawSignature[32:64])) { + return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} + } + if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature[:64]) { + return nil + } + default: + parsedSignature, err := btcec.ParseDERSignature(rawSignature, btcec.S256()) + if err != nil { + return &inputError{ + fmt.Sprintf( + "%s must be a DER or 64/65-byte secp256k1 signature", + name, + ), + } + } + if !isLowSSecp256k1(parsedSignature.S) { + return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} + } + if parsedSignature.Verify(digest, publicKey) { + return nil + } + } + + return &inputError{fmt.Sprintf("%s does not verify against the required public key", name)} +} + +type migrationPlanQuoteSigningPayload struct { + QuoteVersion uint32 `json:"quoteVersion"` + QuoteID string `json:"quoteId"` + ReservationID string `json:"reservationId"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + PlanCommitmentHash string `json:"planCommitmentHash"` + IssuedAt string `json:"issuedAt"` + ExpiresAt string `json:"expiresAt"` + ExpiresInSeconds uint64 `json:"expiresInSeconds"` +} + +func normalizeCanonicalTimestamp(name string, value string) (string, error) { + if !canonicalTimestampPattern.MatchString(value) { + return "", &inputError{ + fmt.Sprintf( + "%s must be a UTC ISO-8601 timestamp from Date.toISOString()", + name, + ), + } + } + + return value, nil +} + +func normalizeMigrationPlanQuotePublicKeyPEM(value string) string { + return strings.TrimSpace(strings.ReplaceAll(value, "\\n", "\n")) +} + +func parseMigrationPlanQuoteTrustRoot( + name string, + trustRoot MigrationPlanQuoteTrustRoot, +) (ed25519.PublicKey, error) { + block, _ := pem.Decode([]byte(normalizeMigrationPlanQuotePublicKeyPEM(trustRoot.PublicKeyPEM))) + if block == nil { + return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded public key", name)} + } + + publicKeyValue, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded Ed25519 public key", name)} + } + + publicKey, ok := publicKeyValue.(ed25519.PublicKey) + if !ok { + return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded Ed25519 public key", name)} + } + + return publicKey, nil +} + +func normalizeScopedApprovalTrustRoot( + name string, + route TemplateID, + reserve string, + network string, + publicKey string, +) (TemplateID, string, string, string, error) { + switch route { + case TemplateSelfV1, TemplateQcV1: + default: + return "", "", "", "", &inputError{ + fmt.Sprintf("%s.route must be self_v1 or qc_v1", name), + } + } + + if err := validateHexString(name+".reserve", reserve); err != nil { + return "", "", "", "", err + } + + trimmedNetwork := strings.TrimSpace(network) + if trimmedNetwork == "" { + return "", "", "", "", &inputError{ + fmt.Sprintf("%s.network is required", name), + } + } + + normalizedPublicKey := normalizeLowerHex(publicKey) + if _, err := parseCompressedSecp256k1PublicKey( + name+".publicKey", + normalizedPublicKey, + ); err != nil { + return "", "", "", "", err + } + + return route, + normalizeLowerHex(reserve), + strings.ToLower(trimmedNetwork), + normalizedPublicKey, + nil +} + +func normalizeDepositorTrustRoots( + trustRoots []DepositorTrustRoot, +) ([]DepositorTrustRoot, error) { + if len(trustRoots) == 0 { + return nil, nil + } + + normalized := make([]DepositorTrustRoot, len(trustRoots)) + seen := make(map[string]int, len(trustRoots)) + + for i, trustRoot := range trustRoots { + name := fmt.Sprintf("depositorTrustRoots[%d]", i) + route, reserve, network, publicKey, err := normalizeScopedApprovalTrustRoot( + name, + trustRoot.Route, + trustRoot.Reserve, + trustRoot.Network, + trustRoot.PublicKey, + ) + if err != nil { + return nil, err + } + + scopeKey := string(route) + "|" + reserve + "|" + network + if previousIndex, ok := seen[scopeKey]; ok { + return nil, &inputError{ + fmt.Sprintf( + "%s duplicates depositorTrustRoots[%d] for route %s reserve %s network %s", + name, + previousIndex, + route, + reserve, + network, + ), + } + } + seen[scopeKey] = i + + normalized[i] = DepositorTrustRoot{ + Route: route, + Reserve: reserve, + Network: network, + PublicKey: publicKey, + } + } + + return normalized, nil +} + +func normalizeCustodianTrustRoots( + trustRoots []CustodianTrustRoot, +) ([]CustodianTrustRoot, error) { + if len(trustRoots) == 0 { + return nil, nil + } + + normalized := make([]CustodianTrustRoot, len(trustRoots)) + seen := make(map[string]int, len(trustRoots)) + + for i, trustRoot := range trustRoots { + name := fmt.Sprintf("custodianTrustRoots[%d]", i) + route, reserve, network, publicKey, err := normalizeScopedApprovalTrustRoot( + name, + trustRoot.Route, + trustRoot.Reserve, + trustRoot.Network, + trustRoot.PublicKey, + ) + if err != nil { + return nil, err + } + + scopeKey := string(route) + "|" + reserve + "|" + network + if previousIndex, ok := seen[scopeKey]; ok { + return nil, &inputError{ + fmt.Sprintf( + "%s duplicates custodianTrustRoots[%d] for route %s reserve %s network %s", + name, + previousIndex, + route, + reserve, + network, + ), + } + } + seen[scopeKey] = i + + normalized[i] = CustodianTrustRoot{ + Route: route, + Reserve: reserve, + Network: network, + PublicKey: publicKey, + } + } + + return normalized, nil +} + +func trustRootLookupScope(request RouteSubmitRequest) (TemplateID, string, string) { + network := "" + if request.MigrationDestination != nil { + network = strings.ToLower(strings.TrimSpace(request.MigrationDestination.Network)) + } + + return request.Route, normalizeLowerHex(request.Reserve), network +} + +func resolveExpectedDepositorPublicKey( + request RouteSubmitRequest, + trustRoots []DepositorTrustRoot, +) (string, bool) { + route, reserve, network := trustRootLookupScope(request) + for _, trustRoot := range trustRoots { + if trustRoot.Route == route && + trustRoot.Reserve == reserve && + trustRoot.Network == network { + return trustRoot.PublicKey, true + } + } + + return "", false +} + +func resolveExpectedCustodianPublicKey( + request RouteSubmitRequest, + trustRoots []CustodianTrustRoot, +) (string, bool) { + route, reserve, network := trustRootLookupScope(request) + for _, trustRoot := range trustRoots { + if trustRoot.Route == route && + trustRoot.Reserve == reserve && + trustRoot.Network == network { + return trustRoot.PublicKey, true + } + } + + return "", false +} + +func migrationPlanQuoteSigningPayloadBytes( + quote *MigrationDestinationPlanQuote, +) ([]byte, error) { + return canonicaljson.Marshal(migrationPlanQuoteSigningPayload{ + QuoteVersion: quote.QuoteVersion, + QuoteID: quote.QuoteID, + ReservationID: quote.ReservationID, + Reserve: normalizeLowerHex(quote.Reserve), + Epoch: quote.Epoch, + Route: string(quote.Route), + Revealer: normalizeLowerHex(quote.Revealer), + Vault: normalizeLowerHex(quote.Vault), + Network: quote.Network, + DestinationCommitmentHash: normalizeLowerHex(quote.DestinationCommitmentHash), + ActiveOutpointTxID: normalizeLowerHex(quote.ActiveOutpointTxID), + ActiveOutpointVout: quote.ActiveOutpointVout, + PlanCommitmentHash: normalizeLowerHex(quote.PlanCommitmentHash), + IssuedAt: quote.IssuedAt, + ExpiresAt: quote.ExpiresAt, + ExpiresInSeconds: quote.ExpiresInSeconds, + }) +} + +func migrationPlanQuoteSigningPreimage( + quote *MigrationDestinationPlanQuote, +) ([]byte, error) { + payload, err := migrationPlanQuoteSigningPayloadBytes(quote) + if err != nil { + return nil, err + } + + return []byte(migrationPlanQuoteSigningDomain + string(payload)), nil +} + +func migrationPlanQuoteSigningHash( + quote *MigrationDestinationPlanQuote, +) ([]byte, error) { + preimage, err := migrationPlanQuoteSigningPreimage(quote) + if err != nil { + return nil, err + } + + sum := sha256.Sum256(preimage) + return sum[:], nil +} + +func normalizeMigrationPlanQuote( + request RouteSubmitRequest, + options validationOptions, +) (*MigrationDestinationPlanQuote, error) { + quote := request.MigrationPlanQuote + if quote == nil { + if len(options.migrationPlanQuoteTrustRoots) > 0 && !options.policyIndependentDigest { + return nil, &inputError{ + "request.migrationPlanQuote is required when migrationPlanQuoteTrustRoots are configured", + } + } + + return nil, nil + } + if len(options.migrationPlanQuoteTrustRoots) == 0 && !options.policyIndependentDigest { + return nil, &inputError{"request.migrationPlanQuote verification requires configured trust roots"} + } + if request.MigrationDestination == nil { + return nil, &inputError{"request.migrationDestination is required when request.migrationPlanQuote is present"} + } + if request.MigrationTransactionPlan == nil { + return nil, &inputError{"request.migrationTransactionPlan is required when request.migrationPlanQuote is present"} + } + if quote.QuoteVersion != migrationPlanQuoteVersion { + return nil, &inputError{"request.migrationPlanQuote.quoteVersion must equal 1"} + } + if strings.TrimSpace(quote.QuoteID) == "" { + return nil, &inputError{"request.migrationPlanQuote.quoteId is required"} + } + if strings.TrimSpace(quote.ReservationID) == "" { + return nil, &inputError{"request.migrationPlanQuote.reservationId is required"} + } + if strings.TrimSpace(quote.IdempotencyKey) == "" { + return nil, &inputError{"request.migrationPlanQuote.idempotencyKey is required"} + } + if quote.Route != ReservationRouteMigration { + return nil, &inputError{"request.migrationPlanQuote.route must be MIGRATION"} + } + if err := validateAddressString("request.migrationPlanQuote.reserve", quote.Reserve); err != nil { + return nil, err + } + if err := validateAddressString("request.migrationPlanQuote.revealer", quote.Revealer); err != nil { + return nil, err + } + if err := validateAddressString("request.migrationPlanQuote.vault", quote.Vault); err != nil { + return nil, err + } + if strings.TrimSpace(quote.Network) == "" { + return nil, &inputError{"request.migrationPlanQuote.network is required"} + } + if err := validateBytes32HexString( + "request.migrationPlanQuote.destinationCommitmentHash", + quote.DestinationCommitmentHash, + ); err != nil { + return nil, err + } + if err := validateBytes32HexString( + "request.migrationPlanQuote.activeOutpointTxid", + quote.ActiveOutpointTxID, + ); err != nil { + return nil, err + } + if err := validateBytes32HexString( + "request.migrationPlanQuote.planCommitmentHash", + quote.PlanCommitmentHash, + ); err != nil { + return nil, err + } + if quote.ExpiresInSeconds == 0 { + return nil, &inputError{"request.migrationPlanQuote.expiresInSeconds must be greater than zero"} + } + if quote.Signature.SignatureVersion != migrationPlanQuoteSignatureVersion { + return nil, &inputError{"request.migrationPlanQuote.signature.signatureVersion must equal 1"} + } + if quote.Signature.Algorithm != migrationPlanQuoteSignatureAlgorithm { + return nil, &inputError{"request.migrationPlanQuote.signature.algorithm must equal ed25519"} + } + if strings.TrimSpace(quote.Signature.KeyID) == "" { + return nil, &inputError{"request.migrationPlanQuote.signature.keyId is required"} + } + if err := validateHexString("request.migrationPlanQuote.signature.signature", quote.Signature.Signature); err != nil { + return nil, err + } + + normalizedIssuedAt, err := normalizeCanonicalTimestamp( + "request.migrationPlanQuote.issuedAt", + quote.IssuedAt, + ) + if err != nil { + return nil, err + } + issuedAt, err := time.Parse(time.RFC3339Nano, normalizedIssuedAt) + if err != nil { + return nil, &inputError{ + "request.migrationPlanQuote.issuedAt must be a parseable UTC ISO-8601 timestamp", + } + } + normalizedExpiresAt, err := normalizeCanonicalTimestamp( + "request.migrationPlanQuote.expiresAt", + quote.ExpiresAt, + ) + if err != nil { + return nil, err + } + expiresAt, err := time.Parse(time.RFC3339Nano, normalizedExpiresAt) + if err != nil { + return nil, &inputError{ + "request.migrationPlanQuote.expiresAt must be a parseable UTC ISO-8601 timestamp", + } + } + if !expiresAt.After(issuedAt) { + return nil, &inputError{"request.migrationPlanQuote.expiresAt must be after request.migrationPlanQuote.issuedAt"} + } + if expiresAt.Sub(issuedAt) != time.Duration(quote.ExpiresInSeconds)*time.Second { + return nil, &inputError{"request.migrationPlanQuote.expiresAt must equal request.migrationPlanQuote.issuedAt + expiresInSeconds"} + } + if quote.Epoch != request.Epoch { + return nil, &inputError{"request.migrationPlanQuote.epoch must match request.epoch"} + } + if normalizeLowerHex(quote.Reserve) != normalizeLowerHex(request.Reserve) { + return nil, &inputError{"request.migrationPlanQuote.reserve must match request.reserve"} + } + if quote.ReservationID != request.MigrationDestination.ReservationID { + return nil, &inputError{"request.migrationPlanQuote.reservationId must match request.migrationDestination.reservationId"} + } + if normalizeLowerHex(quote.Revealer) != normalizeLowerHex(request.MigrationDestination.Revealer) { + return nil, &inputError{"request.migrationPlanQuote.revealer must match request.migrationDestination.revealer"} + } + if normalizeLowerHex(quote.Vault) != normalizeLowerHex(request.MigrationDestination.Vault) { + return nil, &inputError{"request.migrationPlanQuote.vault must match request.migrationDestination.vault"} + } + if strings.TrimSpace(quote.Network) != strings.TrimSpace(request.MigrationDestination.Network) { + return nil, &inputError{"request.migrationPlanQuote.network must match request.migrationDestination.network"} + } + if normalizeLowerHex(quote.DestinationCommitmentHash) != normalizeLowerHex(request.DestinationCommitmentHash) { + return nil, &inputError{"request.migrationPlanQuote.destinationCommitmentHash must match request.destinationCommitmentHash"} + } + if normalizeLowerHex(quote.DestinationCommitmentHash) != normalizeLowerHex(request.MigrationDestination.DestinationCommitmentHash) { + return nil, &inputError{"request.migrationPlanQuote.destinationCommitmentHash must match request.migrationDestination.destinationCommitmentHash"} + } + if normalizeLowerHex(quote.ActiveOutpointTxID) != normalizeLowerHex(request.ActiveOutpoint.TxID) { + return nil, &inputError{"request.migrationPlanQuote.activeOutpointTxid must match request.activeOutpoint.txid"} + } + if quote.ActiveOutpointVout != request.ActiveOutpoint.Vout { + return nil, &inputError{"request.migrationPlanQuote.activeOutpointVout must match request.activeOutpoint.vout"} + } + if normalizeLowerHex(quote.PlanCommitmentHash) != normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { + return nil, &inputError{"request.migrationPlanQuote.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} + } + + normalizedQuotePlan := normalizeMigrationTransactionPlan(quote.MigrationTransactionPlan) + if normalizedQuotePlan == nil { + return nil, &inputError{"request.migrationPlanQuote.migrationTransactionPlan is required"} + } + if err := validateMigrationTransactionPlan(request, quote.MigrationTransactionPlan); err != nil { + return nil, err + } + if !reflect.DeepEqual(normalizedQuotePlan, normalizeMigrationTransactionPlan(request.MigrationTransactionPlan)) { + return nil, &inputError{"request.migrationPlanQuote.migrationTransactionPlan must match request.migrationTransactionPlan"} + } + + normalizedQuote := &MigrationDestinationPlanQuote{ + QuoteID: strings.TrimSpace(quote.QuoteID), + QuoteVersion: migrationPlanQuoteVersion, + ReservationID: strings.TrimSpace(quote.ReservationID), + Reserve: normalizeLowerHex(quote.Reserve), + Epoch: quote.Epoch, + Route: ReservationRouteMigration, + Revealer: normalizeLowerHex(quote.Revealer), + Vault: normalizeLowerHex(quote.Vault), + Network: strings.TrimSpace(quote.Network), + DestinationCommitmentHash: normalizeLowerHex(quote.DestinationCommitmentHash), + ActiveOutpointTxID: normalizeLowerHex(quote.ActiveOutpointTxID), + ActiveOutpointVout: quote.ActiveOutpointVout, + PlanCommitmentHash: normalizeLowerHex(quote.PlanCommitmentHash), + MigrationTransactionPlan: normalizedQuotePlan, + IdempotencyKey: strings.TrimSpace(quote.IdempotencyKey), + ExpiresInSeconds: quote.ExpiresInSeconds, + IssuedAt: normalizedIssuedAt, + ExpiresAt: normalizedExpiresAt, + Signature: MigrationDestinationPlanQuoteSignature{ + SignatureVersion: migrationPlanQuoteSignatureVersion, + Algorithm: migrationPlanQuoteSignatureAlgorithm, + KeyID: strings.TrimSpace(quote.Signature.KeyID), + Signature: normalizeLowerHex(quote.Signature.Signature), + }, + } + if options.policyIndependentDigest { + return normalizedQuote, nil + } + + var publicKey ed25519.PublicKey + foundTrustRoot := false + for i, trustRoot := range options.migrationPlanQuoteTrustRoots { + if trustRoot.KeyID != quote.Signature.KeyID { + continue + } + + publicKey, err = parseMigrationPlanQuoteTrustRoot( + fmt.Sprintf("migrationPlanQuoteTrustRoots[%d]", i), + trustRoot, + ) + if err != nil { + return nil, err + } + foundTrustRoot = true + break + } + if !foundTrustRoot { + return nil, &inputError{"request.migrationPlanQuote.signature.keyId does not match a configured trust root"} + } + + signingHash, err := migrationPlanQuoteSigningHash(normalizedQuote) + if err != nil { + return nil, err + } + + rawSignature, err := hex.DecodeString(strings.TrimPrefix(normalizedQuote.Signature.Signature, "0x")) + if err != nil { + return nil, &inputError{"request.migrationPlanQuote.signature.signature must be valid hex"} + } + if !ed25519.Verify(publicKey, signingHash, rawSignature) { + return nil, &inputError{"request.migrationPlanQuote.signature does not verify against the configured trust root"} + } + + if options.requireFreshMigrationPlanQuote { + verificationNow := options.migrationPlanQuoteVerificationNow + if verificationNow.IsZero() { + verificationNow = time.Now().UTC() + } + // Submit freshness is intentionally strict. Poll omits this check so + // already-accepted jobs remain addressable after quote expiry; operators + // must keep the destination service and keep-core on synchronized UTC + // time when enforcing quote freshness. + if expiresAt.Before(verificationNow) { + return nil, &inputError{"request.migrationPlanQuote is expired"} + } + } + + return normalizedQuote, nil +} + +func computeMigrationExtraData(revealer string) string { + return "0x" + hex.EncodeToString([]byte("AC_MIGRATEV1")) + strings.TrimPrefix(normalizeLowerHex(revealer), "0x") +} + +func computeDepositScriptHash(depositScript string) (string, error) { + rawScript, err := hex.DecodeString(strings.TrimPrefix(depositScript, "0x")) + if err != nil { + return "", err + } + + sum := sha256.Sum256(rawScript) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +type destinationCommitmentPayload struct { + // Field order is hash-significant and must stay aligned with the TypeScript + // reservation-service object literal used to compute the same commitment. + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DepositScriptHash string `json:"depositScriptHash"` + MigrationExtraData string `json:"migrationExtraData"` +} + +type migrationPlanCommitmentPayload struct { + // Field order is hash-significant and must stay aligned with the TypeScript + // migration transaction-plan commitment payload. planCommitmentHash is + // intentionally omitted because it is the output of this computation. + PlanVersion uint32 `json:"planVersion"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + InputValueSats uint64 `json:"inputValueSats"` + DestinationValueSats uint64 `json:"destinationValueSats"` + AnchorValueSats uint64 `json:"anchorValueSats"` + FeeSats uint64 `json:"feeSats"` + InputSequence uint32 `json:"inputSequence"` + LockTime uint32 `json:"lockTime"` +} + +func computeDestinationCommitmentHash( + reservation *MigrationDestinationReservation, +) (string, error) { + payload, err := canonicaljson.Marshal(destinationCommitmentPayload{ + Reserve: normalizeLowerHex(reservation.Reserve), + Epoch: reservation.Epoch, + Route: string(reservation.Route), + Revealer: normalizeLowerHex(reservation.Revealer), + Vault: normalizeLowerHex(reservation.Vault), + Network: strings.TrimSpace(reservation.Network), + DepositScriptHash: normalizeLowerHex(reservation.DepositScriptHash), + MigrationExtraData: normalizeLowerHex(reservation.MigrationExtraData), + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func computeMigrationTransactionPlanCommitmentHash( + request RouteSubmitRequest, + plan *MigrationTransactionPlan, +) (string, error) { + payload, err := canonicaljson.Marshal(migrationPlanCommitmentPayload{ + PlanVersion: plan.PlanVersion, + Reserve: normalizeLowerHex(request.Reserve), + Epoch: request.Epoch, + ActiveOutpointTxID: normalizeLowerHex(request.ActiveOutpoint.TxID), + ActiveOutpointVout: request.ActiveOutpoint.Vout, + DestinationCommitmentHash: normalizeLowerHex(request.DestinationCommitmentHash), + InputValueSats: plan.InputValueSats, + DestinationValueSats: plan.DestinationValueSats, + AnchorValueSats: plan.AnchorValueSats, + FeeSats: plan.FeeSats, + InputSequence: plan.InputSequence, + LockTime: plan.LockTime, + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func validateMigrationDestination( + request RouteSubmitRequest, + reservation *MigrationDestinationReservation, +) error { + if reservation == nil { + return &inputError{"request.migrationDestination is required"} + } + if reservation.Route != ReservationRouteMigration { + return &inputError{"request.migrationDestination.route must be MIGRATION"} + } + if reservation.Status != ReservationStatusReserved && + reservation.Status != ReservationStatusCommittedToEpoch { + return &inputError{"request.migrationDestination.status must be RESERVED or COMMITTED_TO_EPOCH"} + } + if err := validateAddressString("request.migrationDestination.reserve", reservation.Reserve); err != nil { + return err + } + if err := validateAddressString("request.migrationDestination.revealer", reservation.Revealer); err != nil { + return err + } + if err := validateAddressString("request.migrationDestination.vault", reservation.Vault); err != nil { + return err + } + if strings.TrimSpace(reservation.Network) == "" { + return &inputError{"request.migrationDestination.network is required"} + } + if err := validateHexString("request.migrationDestination.depositScript", reservation.DepositScript); err != nil { + return err + } + if err := validateHexString("request.migrationDestination.depositScriptHash", reservation.DepositScriptHash); err != nil { + return err + } + if err := validateHexString("request.migrationDestination.migrationExtraData", reservation.MigrationExtraData); err != nil { + return err + } + if err := validateHexString("request.migrationDestination.destinationCommitmentHash", reservation.DestinationCommitmentHash); err != nil { + return err + } + if request.Epoch != reservation.Epoch { + return &inputError{"request.migrationDestination.epoch does not match request.epoch"} + } + if normalizeLowerHex(request.Reserve) != normalizeLowerHex(reservation.Reserve) { + return &inputError{"request.migrationDestination.reserve does not match request.reserve"} + } + if normalizeLowerHex(request.DestinationCommitmentHash) != normalizeLowerHex(reservation.DestinationCommitmentHash) { + return &inputError{"request.migrationDestination.destinationCommitmentHash does not match request.destinationCommitmentHash"} + } + + expectedExtraData := computeMigrationExtraData(reservation.Revealer) + if normalizeLowerHex(reservation.MigrationExtraData) != expectedExtraData { + return &inputError{"request.migrationDestination.migrationExtraData does not match migration revealer encoding"} + } + + depositScriptHash, err := computeDepositScriptHash(reservation.DepositScript) + if err != nil { + return &inputError{"request.migrationDestination.depositScript is not valid hex"} + } + if normalizeLowerHex(reservation.DepositScriptHash) != depositScriptHash { + return &inputError{"request.migrationDestination.depositScriptHash does not match depositScript"} + } + + commitmentHash, err := computeDestinationCommitmentHash(reservation) + if err != nil { + return err + } + if normalizeLowerHex(reservation.DestinationCommitmentHash) != commitmentHash { + return &inputError{"request.migrationDestination.destinationCommitmentHash does not match canonical reservation artifact"} + } + + return nil +} + +func validateMigrationTransactionPlan( + request RouteSubmitRequest, + plan *MigrationTransactionPlan, +) error { + if plan == nil { + return &inputError{"request.migrationTransactionPlan is required"} + } + if plan.PlanVersion != migrationTransactionPlanVersion { + return &inputError{"request.migrationTransactionPlan.planVersion must equal 1"} + } + if err := validateHexString("request.migrationTransactionPlan.planCommitmentHash", plan.PlanCommitmentHash); err != nil { + return err + } + if plan.InputValueSats == 0 { + return &inputError{"request.migrationTransactionPlan.inputValueSats must be greater than zero"} + } + if plan.DestinationValueSats == 0 { + return &inputError{"request.migrationTransactionPlan.destinationValueSats must be greater than zero"} + } + if plan.FeeSats == 0 { + return &inputError{"request.migrationTransactionPlan.feeSats must be greater than zero"} + } + if plan.AnchorValueSats != canonicalAnchorValueSats { + return &inputError{"request.migrationTransactionPlan.anchorValueSats must equal the canonical 330 sat anchor"} + } + if plan.InputSequence != canonicalCovenantInputSequence { + return &inputError{"request.migrationTransactionPlan.inputSequence must equal 0xFFFFFFFD"} + } + if request.MaturityHeight > math.MaxUint32 { + return &inputError{"request.maturityHeight must fit in uint32"} + } + if uint64(plan.LockTime) != request.MaturityHeight { + return &inputError{"request.migrationTransactionPlan.lockTime must match request.maturityHeight"} + } + if plan.InputValueSats < plan.DestinationValueSats { + return &inputError{"request.migrationTransactionPlan.inputValueSats must cover destinationValueSats"} + } + remainingAfterDestination := plan.InputValueSats - plan.DestinationValueSats + if remainingAfterDestination < plan.AnchorValueSats { + return &inputError{"request.migrationTransactionPlan.inputValueSats must cover anchorValueSats"} + } + remainingAfterAnchor := remainingAfterDestination - plan.AnchorValueSats + if remainingAfterAnchor != plan.FeeSats { + return &inputError{"request.migrationTransactionPlan values must satisfy inputValueSats = destinationValueSats + anchorValueSats + feeSats"} + } + + expectedCommitmentHash, err := computeMigrationTransactionPlanCommitmentHash(request, plan) + if err != nil { + return err + } + if normalizeLowerHex(plan.PlanCommitmentHash) != expectedCommitmentHash { + return &inputError{"request.migrationTransactionPlan.planCommitmentHash does not match canonical migration transaction plan"} + } + + return nil +} + +func validateArtifactSignatures(signatures []string) ([]string, error) { + if len(signatures) == 0 { + return nil, &inputError{"request.artifactSignatures must not be empty"} + } + + normalizedSignatures := make([]string, len(signatures)) + for i, signature := range signatures { + if err := validateHexString( + fmt.Sprintf("request.artifactSignatures[%d]", i), + signature, + ); err != nil { + return nil, err + } + + normalizedSignatures[i] = normalizeLowerHex(signature) + } + + return normalizedSignatures, nil +} + +func requiredStructuredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprovalRole, error) { + switch route { + case TemplateQcV1: + return []ArtifactApprovalRole{ + ArtifactApprovalRoleDepositor, + ArtifactApprovalRoleCustodian, + }, nil + case TemplateSelfV1: + return []ArtifactApprovalRole{ + ArtifactApprovalRoleDepositor, + }, nil + default: + return nil, &inputError{"unsupported request.route"} + } +} + +func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) error { + _, _, _, err := normalizeArtifactApprovals(route, request) + return err +} + +func normalizeArtifactApprovals( + route TemplateID, + request RouteSubmitRequest, +) (*ArtifactApprovalEnvelope, *SignerApprovalCertificate, []string, error) { + normalizedSignerApproval, err := normalizeSignerApprovalCertificate(request) + if err != nil { + return nil, nil, nil, err + } + + normalizedLegacySignatures, err := validateArtifactSignatures(request.ArtifactSignatures) + if err != nil { + return nil, nil, nil, err + } + + if request.ArtifactApprovals == nil { + return nil, normalizedSignerApproval, normalizedLegacySignatures, nil + } + if request.MigrationTransactionPlan == nil { + return nil, nil, nil, &inputError{"request.migrationTransactionPlan is required when request.artifactApprovals is present"} + } + + if request.ArtifactApprovals.Payload.ApprovalVersion != artifactApprovalVersion { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} + } + if request.ArtifactApprovals.Payload.Route != route { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.route must match request.route"} + } + if request.ArtifactApprovals.Payload.ScriptTemplateID != route { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.scriptTemplateId must match request.route"} + } + if err := validateBytes32HexString( + "request.artifactApprovals.payload.destinationCommitmentHash", + request.ArtifactApprovals.Payload.DestinationCommitmentHash, + ); err != nil { + return nil, nil, nil, err + } + if err := validateBytes32HexString( + "request.artifactApprovals.payload.planCommitmentHash", + request.ArtifactApprovals.Payload.PlanCommitmentHash, + ); err != nil { + return nil, nil, nil, err + } + + normalizedDestinationCommitmentHash := normalizeLowerHex( + request.ArtifactApprovals.Payload.DestinationCommitmentHash, + ) + if normalizedDestinationCommitmentHash != normalizeLowerHex(request.DestinationCommitmentHash) { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.destinationCommitmentHash must match request.destinationCommitmentHash"} + } + + normalizedPlanCommitmentHash := normalizeLowerHex( + request.ArtifactApprovals.Payload.PlanCommitmentHash, + ) + if normalizedPlanCommitmentHash != normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} + } + if len(request.ArtifactApprovals.Approvals) == 0 { + return nil, nil, nil, &inputError{"request.artifactApprovals.approvals must not be empty"} + } + + requiredRoles, err := requiredStructuredArtifactApprovalRoles(route) + if err != nil { + return nil, nil, nil, err + } + + allowedRoles := make(map[ArtifactApprovalRole]struct{}, len(requiredRoles)) + for _, role := range requiredRoles { + allowedRoles[role] = struct{}{} + } + + approvalsByRole := make(map[ArtifactApprovalRole]string, len(requiredRoles)) + for i, approval := range request.ArtifactApprovals.Approvals { + if _, ok := allowedRoles[approval.Role]; !ok { + return nil, nil, nil, &inputError{fmt.Sprintf( + "request.artifactApprovals.approvals[%d].role is not allowed for %s", + i, + route, + )} + } + if _, ok := approvalsByRole[approval.Role]; ok { + return nil, nil, nil, &inputError{fmt.Sprintf( + "request.artifactApprovals.approvals[%d].role duplicates role %s", + i, + approval.Role, + )} + } + if err := validateHexString( + fmt.Sprintf("request.artifactApprovals.approvals[%d].signature", i), + approval.Signature, + ); err != nil { + return nil, nil, nil, err + } + + approvalsByRole[approval.Role] = normalizeLowerHex(approval.Signature) + } + + derivedLegacySignatures := make([]string, 0, len(requiredRoles)+1) + normalizedApprovals := &ArtifactApprovalEnvelope{ + Payload: ArtifactApprovalPayload{ + ApprovalVersion: artifactApprovalVersion, + Route: route, + ScriptTemplateID: route, + DestinationCommitmentHash: normalizedDestinationCommitmentHash, + PlanCommitmentHash: normalizedPlanCommitmentHash, + }, + Approvals: make([]ArtifactRoleApproval, len(requiredRoles)), + } + for i, role := range requiredRoles { + signature, ok := approvalsByRole[role] + if !ok { + return nil, nil, nil, &inputError{fmt.Sprintf( + "request.artifactApprovals.approvals must include role %s for %s", + role, + route, + )} + } + + derivedLegacySignatures = append(derivedLegacySignatures, signature) + normalizedApprovals.Approvals[i] = ArtifactRoleApproval{ + Role: role, + Signature: signature, + } + } + + if normalizedSignerApproval != nil { + derivedLegacySignatures = append( + derivedLegacySignatures, + normalizedSignerApproval.Signature, + ) + } + + canonicalSignatureError := "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals" + if normalizedSignerApproval != nil { + canonicalSignatureError = "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals and request.signerApproval" + } + + if len(normalizedLegacySignatures) != len(derivedLegacySignatures) { + return nil, nil, nil, &inputError{canonicalSignatureError} + } + for i := range derivedLegacySignatures { + if normalizedLegacySignatures[i] != derivedLegacySignatures[i] { + return nil, nil, nil, &inputError{canonicalSignatureError} + } + } + + return normalizedApprovals, normalizedSignerApproval, derivedLegacySignatures, nil +} + +func validateArtifactApprovalAuthenticity( + request RouteSubmitRequest, + depositorPublicKey string, + custodianPublicKey string, +) error { + payloadDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + return err + } + + depositorKey, err := parseCompressedSecp256k1PublicKey( + "request.scriptTemplate.depositorPublicKey", + depositorPublicKey, + ) + if err != nil { + return err + } + + var custodianKey *btcec.PublicKey + if custodianPublicKey != "" { + custodianKey, err = parseCompressedSecp256k1PublicKey( + "request.scriptTemplate.custodianPublicKey", + custodianPublicKey, + ) + if err != nil { + return err + } + } + + for i, approval := range request.ArtifactApprovals.Approvals { + signaturePath := fmt.Sprintf( + "request.artifactApprovals.approvals[%d].signature", + i, + ) + + switch approval.Role { + case ArtifactApprovalRoleDepositor: + if err := verifySecp256k1Signature( + signaturePath, + depositorKey, + payloadDigest, + approval.Signature, + ); err != nil { + return err + } + case ArtifactApprovalRoleCustodian: + if custodianKey == nil { + return &inputError{ + "request.artifactApprovals.approvals includes unexpected custodian role", + } + } + if err := verifySecp256k1Signature( + signaturePath, + custodianKey, + payloadDigest, + approval.Signature, + ); err != nil { + return err + } + } + } + + return nil +} + +func normalizeArtifactRecord(record ArtifactRecord) ArtifactRecord { + normalized := ArtifactRecord{ + PSBTHash: normalizeLowerHex(record.PSBTHash), + DestinationCommitmentHash: normalizeLowerHex(record.DestinationCommitmentHash), + } + if record.TransactionHex != "" { + normalized.TransactionHex = normalizeLowerHex(record.TransactionHex) + } + if record.TransactionID != "" { + normalized.TransactionID = normalizeLowerHex(record.TransactionID) + } + + return normalized +} + +func normalizeArtifacts(artifacts map[RecoveryPathID]ArtifactRecord) map[RecoveryPathID]ArtifactRecord { + if artifacts == nil { + return nil + } + + normalized := make(map[RecoveryPathID]ArtifactRecord, len(artifacts)) + for pathID, artifact := range artifacts { + normalized[pathID] = normalizeArtifactRecord(artifact) + } + + return normalized +} + +func normalizeMigrationDestination( + destination *MigrationDestinationReservation, +) *MigrationDestinationReservation { + if destination == nil { + return nil + } + + return &MigrationDestinationReservation{ + ReservationID: destination.ReservationID, + Reserve: normalizeLowerHex(destination.Reserve), + Epoch: destination.Epoch, + Route: destination.Route, + Revealer: normalizeLowerHex(destination.Revealer), + Vault: normalizeLowerHex(destination.Vault), + Network: strings.TrimSpace(destination.Network), + Status: destination.Status, + DepositScript: normalizeLowerHex(destination.DepositScript), + DepositScriptHash: normalizeLowerHex(destination.DepositScriptHash), + MigrationExtraData: normalizeLowerHex(destination.MigrationExtraData), + DestinationCommitmentHash: normalizeLowerHex(destination.DestinationCommitmentHash), + } +} + +func normalizeMigrationTransactionPlan( + plan *MigrationTransactionPlan, +) *MigrationTransactionPlan { + if plan == nil { + return nil + } + + return &MigrationTransactionPlan{ + PlanVersion: plan.PlanVersion, + PlanCommitmentHash: normalizeLowerHex(plan.PlanCommitmentHash), + InputValueSats: plan.InputValueSats, + DestinationValueSats: plan.DestinationValueSats, + AnchorValueSats: plan.AnchorValueSats, + FeeSats: plan.FeeSats, + InputSequence: plan.InputSequence, + LockTime: plan.LockTime, + } +} + +func normalizeScriptTemplate(route TemplateID, rawTemplate json.RawMessage) (json.RawMessage, error) { + switch route { + case TemplateSelfV1: + template := &SelfV1Template{} + if err := strictUnmarshal(rawTemplate, template); err != nil { + return nil, err + } + template.DepositorPublicKey = normalizeLowerHex(template.DepositorPublicKey) + template.SignerPublicKey = normalizeLowerHex(template.SignerPublicKey) + return json.Marshal(template) + case TemplateQcV1: + template := &QcV1Template{} + if err := strictUnmarshal(rawTemplate, template); err != nil { + return nil, err + } + template.DepositorPublicKey = normalizeLowerHex(template.DepositorPublicKey) + template.CustodianPublicKey = normalizeLowerHex(template.CustodianPublicKey) + template.SignerPublicKey = normalizeLowerHex(template.SignerPublicKey) + return json.Marshal(template) + default: + return nil, &inputError{"unsupported request.route"} + } +} + +func normalizeRouteSubmitRequest( + request RouteSubmitRequest, + options validationOptions, +) (RouteSubmitRequest, error) { + normalizedArtifactApprovals, normalizedSignerApproval, normalizedArtifactSignatures, err := normalizeArtifactApprovals( + request.Route, + request, + ) + if err != nil { + return RouteSubmitRequest{}, err + } + + normalizedScriptTemplate, err := normalizeScriptTemplate(request.Route, request.ScriptTemplate) + if err != nil { + return RouteSubmitRequest{}, err + } + + normalizedMigrationPlanQuote, err := normalizeMigrationPlanQuote( + request, + options, + ) + if err != nil { + return RouteSubmitRequest{}, err + } + normalizedRequestType, err := normalizeRequestType(request.Route, request.RequestType) + if err != nil { + return RouteSubmitRequest{}, err + } + + return RouteSubmitRequest{ + FacadeRequestID: request.FacadeRequestID, + IdempotencyKey: request.IdempotencyKey, + RequestType: normalizedRequestType, + Route: request.Route, + Strategy: normalizeLowerHex(request.Strategy), + Reserve: normalizeLowerHex(request.Reserve), + Epoch: request.Epoch, + MaturityHeight: request.MaturityHeight, + ActiveOutpoint: CovenantOutpoint{ + TxID: normalizeLowerHex(request.ActiveOutpoint.TxID), + Vout: request.ActiveOutpoint.Vout, + ScriptHash: func() string { + if request.ActiveOutpoint.ScriptHash == "" { + return "" + } + return normalizeLowerHex(request.ActiveOutpoint.ScriptHash) + }(), + }, + DestinationCommitmentHash: normalizeLowerHex(request.DestinationCommitmentHash), + MigrationDestination: normalizeMigrationDestination(request.MigrationDestination), + MigrationPlanQuote: normalizedMigrationPlanQuote, + MigrationTransactionPlan: normalizeMigrationTransactionPlan(request.MigrationTransactionPlan), + ArtifactApprovals: normalizedArtifactApprovals, + SignerApproval: normalizedSignerApproval, + ArtifactSignatures: normalizedArtifactSignatures, + Artifacts: normalizeArtifacts(request.Artifacts), + ScriptTemplate: normalizedScriptTemplate, + Signing: request.Signing, + }, nil +} + +func validateCommonRequest( + route TemplateID, + request RouteSubmitRequest, + options validationOptions, +) error { + if request.FacadeRequestID == "" { + return &inputError{"request.facadeRequestId is required"} + } + if err := validateRequestIdentifier("request.facadeRequestId", request.FacadeRequestID); err != nil { + return err + } + if request.IdempotencyKey == "" { + return &inputError{"request.idempotencyKey is required"} + } + if err := validateRequestIdentifier("request.idempotencyKey", request.IdempotencyKey); err != nil { + return err + } + if request.Route != route { + return &inputError{"request.route does not match endpoint route"} + } + if _, err := normalizeRequestType(route, request.RequestType); err != nil { + return err + } + if err := validateHexString("request.strategy", request.Strategy); err != nil { + return err + } + if err := validateHexString("request.reserve", request.Reserve); err != nil { + return err + } + if err := validateHexString("request.activeOutpoint.txid", request.ActiveOutpoint.TxID); err != nil { + return err + } + if request.ActiveOutpoint.ScriptHash != "" { + if err := validateHexString("request.activeOutpoint.scriptHash", request.ActiveOutpoint.ScriptHash); err != nil { + return err + } + } + if err := validateHexString("request.destinationCommitmentHash", request.DestinationCommitmentHash); err != nil { + return err + } + // This intentionally creates a deployment ordering constraint: the + // orchestrator must supply the concrete migration destination artifact + // before this signer version can accept requests. + if err := validateMigrationDestination(request, request.MigrationDestination); err != nil { + return err + } + // This intentionally creates the next deployment ordering constraint: the + // orchestrator must supply the canonical migration transaction plan before + // this signer version can accept requests. + if err := validateMigrationTransactionPlan(request, request.MigrationTransactionPlan); err != nil { + return err + } + if _, err := normalizeMigrationPlanQuote(request, options); err != nil { + return err + } + if request.ArtifactApprovals == nil { + return &inputError{"request.artifactApprovals is required"} + } + if options.signerApprovalVerifier != nil && request.SignerApproval == nil { + return &inputError{ + "request.signerApproval is required when the signer approval verifier is configured", + } + } + if err := validateArtifactApprovals(route, request); err != nil { + return err + } + + switch route { + case TemplateSelfV1: + if !request.Signing.SignerRequired || request.Signing.CustodianRequired { + return &inputError{"request.signing must set signerRequired=true and custodianRequired=false for self_v1"} + } + template := &SelfV1Template{} + if err := strictUnmarshal(request.ScriptTemplate, template); err != nil { + return &inputError{fmt.Sprintf("request.scriptTemplate is invalid for self_v1: %v", err)} + } + if template.Template != TemplateSelfV1 { + return &inputError{"request.scriptTemplate.template must be self_v1"} + } + if err := validateHexString("request.scriptTemplate.depositorPublicKey", template.DepositorPublicKey); err != nil { + return err + } + if err := validateHexString("request.scriptTemplate.signerPublicKey", template.SignerPublicKey); err != nil { + return err + } + + depositorPublicKey := template.DepositorPublicKey + if len(options.depositorTrustRoots) > 0 && !options.policyIndependentDigest { + expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( + request, + options.depositorTrustRoots, + ) + if !ok { + return &inputError{ + "request.scriptTemplate.depositorPublicKey requires a matching configured depositorTrustRoots entry for self_v1", + } + } + if normalizeLowerHex(template.DepositorPublicKey) != expectedDepositorPublicKey { + return &inputError{ + "request.scriptTemplate.depositorPublicKey must match the configured depositorTrustRoots publicKey for self_v1", + } + } + depositorPublicKey = expectedDepositorPublicKey + } + + if err := validateArtifactApprovalAuthenticity( + request, + depositorPublicKey, + "", + ); err != nil { + return err + } + case TemplateQcV1: + if !request.Signing.SignerRequired || !request.Signing.CustodianRequired { + return &inputError{"request.signing must set signerRequired=true and custodianRequired=true for qc_v1"} + } + template := &QcV1Template{} + if err := strictUnmarshal(request.ScriptTemplate, template); err != nil { + return &inputError{fmt.Sprintf("request.scriptTemplate is invalid for qc_v1: %v", err)} + } + if template.Template != TemplateQcV1 { + return &inputError{"request.scriptTemplate.template must be qc_v1"} + } + if err := validateHexString("request.scriptTemplate.depositorPublicKey", template.DepositorPublicKey); err != nil { + return err + } + if err := validateHexString("request.scriptTemplate.custodianPublicKey", template.CustodianPublicKey); err != nil { + return err + } + if err := validateHexString("request.scriptTemplate.signerPublicKey", template.SignerPublicKey); err != nil { + return err + } + + depositorPublicKey := template.DepositorPublicKey + if len(options.depositorTrustRoots) > 0 && !options.policyIndependentDigest { + expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( + request, + options.depositorTrustRoots, + ) + if !ok { + return &inputError{ + "request.scriptTemplate.depositorPublicKey requires a matching configured depositorTrustRoots entry for qc_v1", + } + } + if normalizeLowerHex(template.DepositorPublicKey) != expectedDepositorPublicKey { + return &inputError{ + "request.scriptTemplate.depositorPublicKey must match the configured depositorTrustRoots publicKey for qc_v1", + } + } + depositorPublicKey = expectedDepositorPublicKey + } + + custodianPublicKey := template.CustodianPublicKey + if len(options.custodianTrustRoots) > 0 && !options.policyIndependentDigest { + expectedCustodianPublicKey, ok := resolveExpectedCustodianPublicKey( + request, + options.custodianTrustRoots, + ) + if !ok { + return &inputError{ + "request.scriptTemplate.custodianPublicKey requires a matching configured custodianTrustRoots entry for qc_v1", + } + } + if normalizeLowerHex(template.CustodianPublicKey) != expectedCustodianPublicKey { + return &inputError{ + "request.scriptTemplate.custodianPublicKey must match the configured custodianTrustRoots publicKey for qc_v1", + } + } + custodianPublicKey = expectedCustodianPublicKey + } + + if err := validateArtifactApprovalAuthenticity( + request, + depositorPublicKey, + custodianPublicKey, + ); err != nil { + return err + } + default: + return &inputError{"unsupported request.route"} + } + + if request.SignerApproval != nil { + if options.policyIndependentDigest { + return nil + } + if options.signerApprovalVerifier == nil { + return &inputError{ + "request.signerApproval cannot be verified by this signer deployment", + } + } + + normalizedRequest, err := normalizeRouteSubmitRequest(request, options) + if err != nil { + return err + } + + if err := options.signerApprovalVerifier.VerifySignerApproval( + normalizedRequest, + ); err != nil { + return err + } + } + + return nil +} + +func validateSubmitInput( + route TemplateID, + input SignerSubmitInput, + options validationOptions, +) error { + if input.RouteRequestID == "" { + return &inputError{"routeRequestId is required"} + } + if input.Stage != StageSignerCoordination { + return &inputError{"stage must be SIGNER_COORDINATION"} + } + return validateCommonRequest(route, input.Request, options) +} + +func validatePollInput( + route TemplateID, + input SignerPollInput, + options validationOptions, +) error { + if input.RequestID == "" { + return &inputError{"requestId is required"} + } + if err := validateSubmitInput(route, SignerSubmitInput{ + RouteRequestID: input.RouteRequestID, + Request: input.Request, + Stage: input.Stage, + }, options); err != nil { + return err + } + return nil +} diff --git a/pkg/covenantsigner/validation_fuzz_test.go b/pkg/covenantsigner/validation_fuzz_test.go new file mode 100644 index 0000000000..10529fcf15 --- /dev/null +++ b/pkg/covenantsigner/validation_fuzz_test.go @@ -0,0 +1,18 @@ +package covenantsigner + +import "testing" + +func FuzzParseMigrationPlanQuoteTrustRoot_NoPanic(f *testing.F) { + f.Add("trustRoot", "not a pem") + f.Add("trustRoot", "-----BEGIN PUBLIC KEY-----\nZm9v\n-----END PUBLIC KEY-----") + + f.Fuzz(func(t *testing.T, name string, publicKeyPEM string) { + _, _ = parseMigrationPlanQuoteTrustRoot( + name, + MigrationPlanQuoteTrustRoot{ + KeyID: "fuzz", + PublicKeyPEM: publicKeyPEM, + }, + ) + }) +} diff --git a/pkg/generator/scheduler.go b/pkg/generator/scheduler.go index 73c9d25350..3642133be6 100644 --- a/pkg/generator/scheduler.go +++ b/pkg/generator/scheduler.go @@ -112,6 +112,7 @@ 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 -- cancelFn is stored in s.stops and invoked by stop(). ctx, cancelFn := context.WithCancel(context.Background()) s.stops = append(s.stops, cancelFn) diff --git a/pkg/internal/canonicaljson/marshal.go b/pkg/internal/canonicaljson/marshal.go new file mode 100644 index 0000000000..d30c276b1d --- /dev/null +++ b/pkg/internal/canonicaljson/marshal.go @@ -0,0 +1,23 @@ +// Package canonicaljson provides deterministic JSON marshaling without +// trailing newlines or HTML escaping. +package canonicaljson + +import ( + "bytes" + "encoding/json" +) + +// Marshal encodes the given value as JSON without HTML escaping and strips +// the trailing newline that json.Encoder.Encode appends. The result is +// suitable for hashing where byte-level determinism matters. +func Marshal(v any) ([]byte, error) { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + + if err := encoder.Encode(v); err != nil { + return nil, err + } + + return bytes.TrimSuffix(buffer.Bytes(), []byte("\n")), nil +} diff --git a/pkg/internal/canonicaljson/marshal_test.go b/pkg/internal/canonicaljson/marshal_test.go new file mode 100644 index 0000000000..2529619f12 --- /dev/null +++ b/pkg/internal/canonicaljson/marshal_test.go @@ -0,0 +1,135 @@ +package canonicaljson + +import ( + "bytes" + "strings" + "testing" +) + +// testPayload mirrors the signerApprovalCertificateSignerSetPayload struct +// pattern used in production code, with string and int fields using camelCase +// JSON tags. +type testPayload struct { + WalletID string `json:"walletId"` + Threshold int `json:"threshold"` +} + +func TestMarshal_MapInput(t *testing.T) { + input := map[string]any{ + "name": "test", + "active": true, + "count": 3, + } + + result, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Keys must be alphabetically sorted by json.Encoder. + expected := `{"active":true,"count":3,"name":"test"}` + if string(result) != expected { + t.Fatalf( + "unexpected result\nexpected: %s\nactual: %s", + expected, + string(result), + ) + } +} + +func TestMarshal_StructInput(t *testing.T) { + input := testPayload{ + WalletID: "0xabc", + Threshold: 51, + } + + result, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // JSON tags determine field names; struct declaration order is preserved. + expected := `{"walletId":"0xabc","threshold":51}` + if string(result) != expected { + t.Fatalf( + "unexpected result\nexpected: %s\nactual: %s", + expected, + string(result), + ) + } +} + +func TestMarshal_NoTrailingNewline(t *testing.T) { + input := map[string]int{"count": 42} + + result, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if bytes.HasSuffix(result, []byte("\n")) { + t.Fatalf("output ends with trailing newline: %q", result) + } +} + +func TestMarshal_HTMLNotEscaped(t *testing.T) { + input := map[string]string{"html": "bold & fun > safe"} + + result, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := string(result) + + // Verify Unicode escape sequences are absent. + for _, escaped := range []string{`\u003c`, `\u003e`, `\u0026`} { + if strings.Contains(output, escaped) { + t.Fatalf("found unwanted escape %s in output: %s", escaped, output) + } + } + + // Verify raw HTML characters are present. + for _, raw := range []string{"<", ">", "&"} { + if !strings.Contains(output, raw) { + t.Fatalf("missing raw character %q in output: %s", raw, output) + } + } +} + +func TestMarshal_DeterministicMapOrder(t *testing.T) { + input := map[string]string{ + "zebra": "last", + "alpha": "first", + "middle": "center", + } + + first, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify alphabetical key ordering. + expected := `{"alpha":"first","middle":"center","zebra":"last"}` + if string(first) != expected { + t.Fatalf( + "unexpected key order\nexpected: %s\nactual: %s", + expected, + string(first), + ) + } + + // Verify repeated calls produce byte-identical output. + for i := 0; i < 10; i++ { + result, err := Marshal(input) + if err != nil { + t.Fatalf("iteration %d: unexpected error: %v", i, err) + } + if !bytes.Equal(first, result) { + t.Fatalf( + "iteration %d: output differs\nexpected: %s\nactual: %s", + i, first, result, + ) + } + } +} diff --git a/pkg/tbtc/bitcoin_chain_test.go b/pkg/tbtc/bitcoin_chain_test.go index 0e87791939..7beb691865 100644 --- a/pkg/tbtc/bitcoin_chain_test.go +++ b/pkg/tbtc/bitcoin_chain_test.go @@ -11,6 +11,7 @@ import ( type localBitcoinChain struct { transactionsMutex sync.Mutex transactions []*bitcoin.Transaction + confirmations map[bitcoin.Hash]uint mempoolMutex sync.Mutex mempool []*bitcoin.Transaction @@ -18,8 +19,9 @@ type localBitcoinChain struct { func newLocalBitcoinChain() *localBitcoinChain { return &localBitcoinChain{ - transactions: make([]*bitcoin.Transaction, 0), - mempool: make([]*bitcoin.Transaction, 0), + transactions: make([]*bitcoin.Transaction, 0), + confirmations: make(map[bitcoin.Hash]uint), + mempool: make([]*bitcoin.Transaction, 0), } } @@ -41,6 +43,10 @@ func (lbc *localBitcoinChain) GetTransaction( func (lbc *localBitcoinChain) GetTransactionConfirmations( transactionHash bitcoin.Hash, ) (uint, error) { + if confirmations, ok := lbc.confirmations[transactionHash]; ok { + return confirmations, nil + } + for index, transaction := range lbc.transactions { if transaction.Hash() == transactionHash { confirmations := len(lbc.transactions) - index @@ -51,6 +57,13 @@ func (lbc *localBitcoinChain) GetTransactionConfirmations( return 0, fmt.Errorf("transaction not found") } +func (lbc *localBitcoinChain) setTransactionConfirmations( + transactionHash bitcoin.Hash, + confirmations uint, +) { + lbc.confirmations[transactionHash] = confirmations +} + func (lbc *localBitcoinChain) BroadcastTransaction( transaction *bitcoin.Transaction, ) error { diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 55206f86fb..c70e4b73c0 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -2,6 +2,7 @@ package tbtc import ( "crypto/ecdsa" + "errors" "math/big" "time" @@ -17,6 +18,8 @@ import ( type DKGState int +var ErrWalletNotFound = errors.New("wallet not found") + const ( Idle DKGState = iota AwaitingSeed @@ -414,6 +417,7 @@ type DepositChainRequest struct { // WalletChainData represents wallet data stored on-chain. type WalletChainData struct { EcdsaWalletID [32]byte + MembersIDsHash [32]byte MainUtxoHash [32]byte PendingRedemptionsValue uint64 CreatedAt time.Time diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 15bb4c94ca..cbd59b5221 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -886,7 +886,7 @@ func (lc *localChain) GetWallet(walletPublicKeyHash [20]byte) ( walletChainData, ok := lc.wallets[walletPublicKeyHash] if !ok { - return nil, fmt.Errorf("no wallet for given PKH") + return nil, fmt.Errorf("%w for given PKH", ErrWalletNotFound) } return walletChainData, nil diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go new file mode 100644 index 0000000000..f0104a3e33 --- /dev/null +++ b/pkg/tbtc/covenant_signer.go @@ -0,0 +1,951 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/txscript" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +type covenantSignerEngine struct { + node *node + minimumActiveOutpointConfirmations uint +} + +// defaultMinActiveOutpointConfirmations is the confirmation threshold applied +// when the operator config does not specify a custom value. It aligns with +// DepositSweepRequiredFundingTxConfirmations to ensure consistent reorg safety +// across the tBTC subsystem. +const defaultMinActiveOutpointConfirmations uint = 6 + +const qcV1SignerHandoffKind = "qc_v1_signer_handoff_v1" + +type qcV1SignerHandoff struct { + Kind string + SignerRequestID string + BundleID string + DestinationCommitmentHash string + PayloadHash string + UnsignedTransactionHex string + WitnessScript string + SignerSignature string + SelectorWitnessItems []string + RequiresDummy bool + SighashType uint32 +} + +// newCovenantSignerEngine creates a covenant signer engine bound to the given +// node. When minConfirmations is zero (the Go zero-value produced by an unset +// config field), defaultMinActiveOutpointConfirmations is used. +func newCovenantSignerEngine(node *node, minConfirmations uint) covenantsigner.Engine { + if minConfirmations == 0 { + minConfirmations = defaultMinActiveOutpointConfirmations + } + + return &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: minConfirmations, + } +} + +func (cse *covenantSignerEngine) VerifySignerApproval( + request covenantsigner.RouteSubmitRequest, +) error { + if request.SignerApproval == nil { + return covenantsigner.NewInputError( + "request.signerApproval is required for signer approval verification", + ) + } + if request.ArtifactApprovals == nil { + return covenantsigner.NewInputError( + "request.artifactApprovals is required for signer approval verification", + ) + } + + expectedApprovalDigest, err := covenantsigner.ComputeArtifactApprovalDigest( + request.ArtifactApprovals.Payload, + ) + if err != nil { + return covenantsigner.NewInputError( + fmt.Sprintf( + "request.artifactApprovals.payload is invalid for signer approval verification: %v", + err, + ), + ) + } + if !strings.EqualFold( + request.SignerApproval.ApprovalDigest, + "0x"+hex.EncodeToString(expectedApprovalDigest), + ) { + return covenantsigner.NewInputError( + "request.signerApproval.approvalDigest must match request.artifactApprovals.payload", + ) + } + + signerPublicKey, err := cse.resolveSignerApprovalTemplatePublicKey(request) + if err != nil { + return covenantsigner.NewInputError(err.Error()) + } + + expectedWalletPublicKeyBytes, err := marshalPublicKey(signerPublicKey) + if err != nil { + return fmt.Errorf( + "cannot marshal signer public key for signer approval verification: %w", + err, + ) + } + + expectedWalletPublicKey := "0x" + hex.EncodeToString(expectedWalletPublicKeyBytes) + if !strings.EqualFold( + request.SignerApproval.WalletPublicKey, + expectedWalletPublicKey, + ) { + return covenantsigner.NewInputError( + "request.signerApproval.walletPublicKey must match request.scriptTemplate.signerPublicKey", + ) + } + + walletChainData, err := cse.node.chain.GetWallet( + bitcoin.PublicKeyHash(signerPublicKey), + ) + if err != nil { + if errors.Is(err, ErrWalletNotFound) { + return covenantsigner.NewInputError( + "request.signerApproval.walletPublicKey must resolve to a registered on-chain wallet", + ) + } + + return fmt.Errorf( + "cannot resolve on-chain wallet for signer approval verification: %w", + err, + ) + } + + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + signerPublicKey, + walletChainData, + cse.node.groupParameters, + ) + if err != nil { + return fmt.Errorf( + "cannot compute signer approval signer set hash: %w", + err, + ) + } + + if err := verifySignerApprovalCertificate( + request.SignerApproval, + expectedSignerSetHash, + ); err != nil { + return covenantsigner.NewInputError( + fmt.Sprintf("request.signerApproval is invalid: %v", err), + ) + } + + return nil +} + +func (cse *covenantSignerEngine) resolveSignerApprovalTemplatePublicKey( + request covenantsigner.RouteSubmitRequest, +) (*ecdsa.PublicKey, error) { + switch request.Route { + case covenantsigner.TemplateSelfV1: + template, err := decodeSelfV1Template(request.ScriptTemplate) + if err != nil { + return nil, err + } + return parseCompressedPublicKey(template.SignerPublicKey) + case covenantsigner.TemplateQcV1: + template, err := decodeQcV1Template(request.ScriptTemplate) + if err != nil { + return nil, err + } + return parseCompressedPublicKey(template.SignerPublicKey) + default: + return nil, fmt.Errorf("unsupported covenant route") + } +} + +func (cse *covenantSignerEngine) OnSubmit( + ctx context.Context, + job *covenantsigner.Job, +) (*covenantsigner.Transition, error) { + switch job.Route { + case covenantsigner.TemplateSelfV1: + return cse.submitSelfV1(ctx, job), nil + case covenantsigner.TemplateQcV1: + return cse.submitQcV1(ctx, job), nil + default: + return &covenantsigner.Transition{ + State: covenantsigner.JobStateFailed, + Reason: covenantsigner.ReasonInvalidInput, + Detail: "unsupported covenant route", + }, nil + } +} + +func (cse *covenantSignerEngine) OnPoll( + context.Context, + *covenantsigner.Job, +) (*covenantsigner.Transition, error) { + return nil, nil +} + +func (cse *covenantSignerEngine) submitSelfV1( + ctx context.Context, + job *covenantsigner.Job, +) *covenantsigner.Transition { + template, err := decodeSelfV1Template(job.Request.ScriptTemplate) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + walletPublicKey, err := parseCompressedPublicKey(template.SignerPublicKey) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, "invalid self_v1 signer public key") + } + + signingExecutor, ok, err := cse.node.getSigningExecutor(walletPublicKey) + if err != nil { + return failedTransition(covenantsigner.ReasonProviderFailed, fmt.Sprintf("cannot resolve signing executor: %v", err)) + } + if !ok { + return failedTransition(covenantsigner.ReasonPolicyRejected, "wallet is not controlled by this node") + } + + witnessScript, err := buildSelfV1WitnessScript(template, job.Request.MaturityHeight) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + activeUtxo, err := cse.resolveSelfV1ActiveUtxo(job.Request, witnessScript) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + if err := validateMigrationOutputValues(job.Request); err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + transaction, err := cse.buildAndSignSelfV1Transaction( + ctx, + signingExecutor, + job.Request, + activeUtxo, + witnessScript, + ) + if err != nil { + return failedTransition(covenantsigner.ReasonProviderFailed, err.Error()) + } + + transactionHex := "0x" + hex.EncodeToString(transaction.Serialize(bitcoin.Witness)) + + // Until the wider stack standardizes a PSBT-native artifact hash, + // return a deterministic 32-byte artifact identifier derived from the + // final witness transaction serialization. + psbtHash := "0x" + transaction.WitnessHash().Hex(bitcoin.InternalByteOrder) + + return &covenantsigner.Transition{ + State: covenantsigner.JobStateArtifactReady, + Detail: func() string { + if job.Request.RequestType == covenantsigner.RequestTypePresignSelfV1 { + return "self_v1 presign artifact ready" + } + return "self_v1 artifact ready" + }(), + PSBTHash: psbtHash, + TransactionHex: transactionHex, + } +} + +func (cse *covenantSignerEngine) submitQcV1( + ctx context.Context, + job *covenantsigner.Job, +) *covenantsigner.Transition { + template, err := decodeQcV1Template(job.Request.ScriptTemplate) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + walletPublicKey, err := parseCompressedPublicKey(template.SignerPublicKey) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, "invalid qc_v1 signer public key") + } + + signingExecutor, ok, err := cse.node.getSigningExecutor(walletPublicKey) + if err != nil { + return failedTransition(covenantsigner.ReasonProviderFailed, fmt.Sprintf("cannot resolve signing executor: %v", err)) + } + if !ok { + return failedTransition(covenantsigner.ReasonPolicyRejected, "wallet is not controlled by this node") + } + + witnessScript, err := buildQcV1WitnessScript(template, job.Request.MaturityHeight) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + activeUtxo, err := cse.resolveQcV1ActiveUtxo(job.Request, witnessScript) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + if err := validateMigrationOutputValues(job.Request); err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + handoff, err := cse.buildQcV1SignerHandoff( + ctx, + job.RequestID, + signingExecutor, + job.Request, + activeUtxo, + witnessScript, + ) + if err != nil { + return failedTransition(covenantsigner.ReasonProviderFailed, err.Error()) + } + + return &covenantsigner.Transition{ + State: covenantsigner.JobStateHandoffReady, + Detail: "qc_v1 signer handoff ready for custodian coordination", + Handoff: handoff.toMap(), + } +} + +func decodeSelfV1Template(raw json.RawMessage) (*covenantsigner.SelfV1Template, error) { + template := &covenantsigner.SelfV1Template{} + if err := json.Unmarshal(raw, template); err != nil { + return nil, fmt.Errorf("cannot decode self_v1 template: %v", err) + } + if template.Template != covenantsigner.TemplateSelfV1 { + return nil, fmt.Errorf("request template must be self_v1") + } + return template, nil +} + +func decodeQcV1Template(raw json.RawMessage) (*covenantsigner.QcV1Template, error) { + template := &covenantsigner.QcV1Template{} + if err := json.Unmarshal(raw, template); err != nil { + return nil, fmt.Errorf("cannot decode qc_v1 template: %v", err) + } + if template.Template != covenantsigner.TemplateQcV1 { + return nil, fmt.Errorf("request template must be qc_v1") + } + return template, nil +} + +func parseCompressedPublicKey(encoded string) (*ecdsa.PublicKey, error) { + bytes, err := canonicalCompressedPublicKeyBytes(encoded) + if err != nil { + return nil, err + } + + parsed, err := btcec.ParsePubKey(bytes, btcec.S256()) + if err != nil { + return nil, err + } + + return &ecdsa.PublicKey{ + Curve: tecdsa.Curve, + X: parsed.X, + Y: parsed.Y, + }, nil +} + +func buildSelfV1WitnessScript( + template *covenantsigner.SelfV1Template, + maturityHeight uint64, +) (bitcoin.Script, error) { + if maturityHeight == 0 { + return nil, fmt.Errorf("maturity height must be greater than zero") + } + if maturityHeight > math.MaxUint32 { + return nil, fmt.Errorf("maturity height exceeds bitcoin locktime range") + } + if template.Delta2 > math.MaxUint32 || maturityHeight > math.MaxUint32-template.Delta2 { + return nil, fmt.Errorf("self_v1 delta2 overflows bitcoin locktime range") + } + + depositorPublicKey, err := canonicalCompressedPublicKeyBytes(template.DepositorPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid self_v1 depositor public key") + } + signerPublicKey, err := canonicalCompressedPublicKeyBytes(template.SignerPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid self_v1 signer public key") + } + + maturityScriptNumber, err := encodeScriptNumber(uint32(maturityHeight)) + if err != nil { + return nil, err + } + lastResortScriptNumber, err := encodeScriptNumber(uint32(maturityHeight + template.Delta2)) + if err != nil { + return nil, err + } + + return txscript.NewScriptBuilder(). + AddOp(txscript.OP_IF). + AddOp(txscript.OP_2). + AddData(depositorPublicKey). + AddData(signerPublicKey). + AddOp(txscript.OP_2). + AddOp(txscript.OP_CHECKMULTISIG). + AddOp(txscript.OP_ELSE). + AddOp(txscript.OP_IF). + AddData(maturityScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddData(signerPublicKey). + AddOp(txscript.OP_CHECKSIG). + AddOp(txscript.OP_ELSE). + AddData(lastResortScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddData(depositorPublicKey). + AddOp(txscript.OP_CHECKSIG). + AddOp(txscript.OP_ENDIF). + AddOp(txscript.OP_ENDIF). + Script() +} + +func buildQcV1WitnessScript( + template *covenantsigner.QcV1Template, + maturityHeight uint64, +) (bitcoin.Script, error) { + if maturityHeight == 0 { + return nil, fmt.Errorf("maturity height must be greater than zero") + } + if maturityHeight > math.MaxUint32 { + return nil, fmt.Errorf("maturity height exceeds bitcoin locktime range") + } + if template.Beta > math.MaxUint32 || template.Beta >= maturityHeight { + return nil, fmt.Errorf("qc_v1 beta must be below maturity height") + } + if template.Delta2 > math.MaxUint32 || maturityHeight > math.MaxUint32-template.Delta2 { + return nil, fmt.Errorf("qc_v1 delta2 overflows bitcoin locktime range") + } + + depositorPublicKey, err := canonicalCompressedPublicKeyBytes(template.DepositorPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid qc_v1 depositor public key") + } + custodianPublicKey, err := canonicalCompressedPublicKeyBytes(template.CustodianPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid qc_v1 custodian public key") + } + signerPublicKey, err := canonicalCompressedPublicKeyBytes(template.SignerPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid qc_v1 signer public key") + } + + maturityScriptNumber, err := encodeScriptNumber(uint32(maturityHeight)) + if err != nil { + return nil, err + } + earlyExitScriptNumber, err := encodeScriptNumber(uint32(maturityHeight - template.Beta)) + if err != nil { + return nil, err + } + lastResortScriptNumber, err := encodeScriptNumber(uint32(maturityHeight + template.Delta2)) + if err != nil { + return nil, err + } + + return txscript.NewScriptBuilder(). + AddOp(txscript.OP_IF). + AddOp(txscript.OP_3). + AddData(depositorPublicKey). + AddData(custodianPublicKey). + AddData(signerPublicKey). + AddOp(txscript.OP_3). + AddOp(txscript.OP_CHECKMULTISIG). + AddOp(txscript.OP_ELSE). + AddOp(txscript.OP_IF). + AddData(maturityScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddOp(txscript.OP_2). + AddData(signerPublicKey). + AddData(custodianPublicKey). + AddOp(txscript.OP_2). + AddOp(txscript.OP_CHECKMULTISIG). + AddOp(txscript.OP_ELSE). + AddOp(txscript.OP_IF). + AddData(earlyExitScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddOp(txscript.OP_2). + AddData(depositorPublicKey). + AddData(custodianPublicKey). + AddOp(txscript.OP_2). + AddOp(txscript.OP_CHECKMULTISIG). + AddOp(txscript.OP_ELSE). + AddData(lastResortScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddData(depositorPublicKey). + AddOp(txscript.OP_CHECKSIG). + AddOp(txscript.OP_ENDIF). + AddOp(txscript.OP_ENDIF). + AddOp(txscript.OP_ENDIF). + Script() +} + +func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( + request covenantsigner.RouteSubmitRequest, + witnessScript bitcoin.Script, +) (*bitcoin.UnspentTransactionOutput, error) { + activeTxHash, err := bitcoin.NewHashFromString( + strings.TrimPrefix(request.ActiveOutpoint.TxID, "0x"), + bitcoin.ReversedByteOrder, + ) + if err != nil { + return nil, fmt.Errorf("active outpoint txid is invalid") + } + + transaction, err := cse.node.btcChain.GetTransaction(activeTxHash) + if err != nil { + return nil, fmt.Errorf("active outpoint transaction not found") + } + if err := cse.ensureActiveOutpointFinality(activeTxHash); err != nil { + return nil, err + } + if int(request.ActiveOutpoint.Vout) >= len(transaction.Outputs) { + return nil, fmt.Errorf("active outpoint output index is out of range") + } + + expectedWitnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + expectedScriptPubKey, err := bitcoin.PayToWitnessScriptHash(expectedWitnessScriptHash) + if err != nil { + return nil, fmt.Errorf("cannot build expected self_v1 locking script: %v", err) + } + + actualOutput := transaction.Outputs[request.ActiveOutpoint.Vout] + if !bytes.Equal(actualOutput.PublicKeyScript, expectedScriptPubKey) { + return nil, fmt.Errorf("active outpoint script does not match self_v1 template") + } + if actualOutput.Value <= 0 { + return nil, fmt.Errorf("active outpoint value must be greater than zero") + } + if uint64(actualOutput.Value) != request.MigrationTransactionPlan.InputValueSats { + return nil, fmt.Errorf("active outpoint value does not match migration transaction plan") + } + + if request.ActiveOutpoint.ScriptHash != "" { + // The optional scriptHash convention follows the tBTC-side request + // contract: sha256(scriptPubKey) for the active covenant output. + scriptHash := sha256.Sum256(expectedScriptPubKey) + expectedScriptHash := "0x" + hex.EncodeToString(scriptHash[:]) + if strings.ToLower(request.ActiveOutpoint.ScriptHash) != expectedScriptHash { + return nil, fmt.Errorf("active outpoint script hash does not match self_v1 template") + } + } + + return &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: activeTxHash, + OutputIndex: request.ActiveOutpoint.Vout, + }, + Value: actualOutput.Value, + }, nil +} + +func (cse *covenantSignerEngine) resolveQcV1ActiveUtxo( + request covenantsigner.RouteSubmitRequest, + witnessScript bitcoin.Script, +) (*bitcoin.UnspentTransactionOutput, error) { + activeTxHash, err := bitcoin.NewHashFromString( + strings.TrimPrefix(request.ActiveOutpoint.TxID, "0x"), + bitcoin.ReversedByteOrder, + ) + if err != nil { + return nil, fmt.Errorf("active outpoint txid is invalid") + } + + transaction, err := cse.node.btcChain.GetTransaction(activeTxHash) + if err != nil { + return nil, fmt.Errorf("active outpoint transaction not found") + } + if err := cse.ensureActiveOutpointFinality(activeTxHash); err != nil { + return nil, err + } + if int(request.ActiveOutpoint.Vout) >= len(transaction.Outputs) { + return nil, fmt.Errorf("active outpoint output index is out of range") + } + + expectedWitnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + expectedScriptPubKey, err := bitcoin.PayToWitnessScriptHash(expectedWitnessScriptHash) + if err != nil { + return nil, fmt.Errorf("cannot build expected qc_v1 locking script: %v", err) + } + + actualOutput := transaction.Outputs[request.ActiveOutpoint.Vout] + if !bytes.Equal(actualOutput.PublicKeyScript, expectedScriptPubKey) { + return nil, fmt.Errorf("active outpoint script does not match qc_v1 template") + } + if actualOutput.Value <= 0 { + return nil, fmt.Errorf("active outpoint value must be greater than zero") + } + if uint64(actualOutput.Value) != request.MigrationTransactionPlan.InputValueSats { + return nil, fmt.Errorf("active outpoint value does not match migration transaction plan") + } + + if request.ActiveOutpoint.ScriptHash != "" { + // The optional scriptHash convention follows the tBTC-side request + // contract: sha256(scriptPubKey) for the active covenant output. + scriptHash := sha256.Sum256(expectedScriptPubKey) + expectedScriptHash := "0x" + hex.EncodeToString(scriptHash[:]) + if strings.ToLower(request.ActiveOutpoint.ScriptHash) != expectedScriptHash { + return nil, fmt.Errorf("active outpoint script hash does not match qc_v1 template") + } + } + + return &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: activeTxHash, + OutputIndex: request.ActiveOutpoint.Vout, + }, + Value: actualOutput.Value, + }, nil +} + +func (cse *covenantSignerEngine) ensureActiveOutpointFinality( + activeTxHash bitcoin.Hash, +) error { + confirmations, err := cse.node.btcChain.GetTransactionConfirmations(activeTxHash) + if err != nil { + return fmt.Errorf("cannot determine active outpoint transaction confirmations: %v", err) + } + if confirmations < cse.minimumActiveOutpointConfirmations { + return fmt.Errorf( + "active outpoint transaction must have at least %d confirmations", + cse.minimumActiveOutpointConfirmations, + ) + } + + return nil +} + +func validateMigrationOutputValues(request covenantsigner.RouteSubmitRequest) error { + _, err := toBitcoinOutputValue( + request.MigrationTransactionPlan.DestinationValueSats, + "migration destination value", + ) + if err != nil { + return err + } + + _, err = toBitcoinOutputValue( + request.MigrationTransactionPlan.AnchorValueSats, + "migration anchor value", + ) + return err +} + +func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( + ctx context.Context, + signingExecutor *signingExecutor, + request covenantsigner.RouteSubmitRequest, + activeUtxo *bitcoin.UnspentTransactionOutput, + witnessScript bitcoin.Script, +) (*bitcoin.Transaction, error) { + builder, err := cse.buildCovenantTransactionBuilder( + request, + activeUtxo, + witnessScript, + ) + if err != nil { + return nil, err + } + signature, err := signCovenantTransactionInput(ctx, signingExecutor, builder) + if err != nil { + return nil, err + } + + witness, err := buildSelfV1MigrationWitness(signature, witnessScript) + if err != nil { + return nil, err + } + if err := builder.SetInputWitness(0, witness); err != nil { + return nil, fmt.Errorf("cannot set covenant witness: %v", err) + } + + transaction := builder.Build() + if len(transaction.Inputs) != 1 { + return nil, fmt.Errorf("unexpected covenant input count") + } + if len(transaction.Inputs[0].Witness) == 0 { + return nil, fmt.Errorf("unexpected empty covenant witness stack") + } + if !bytes.Equal(transaction.Inputs[0].Witness[len(transaction.Inputs[0].Witness)-1], witnessScript) { + // This can never happen with the current builder path, but keeping the + // explicit comparison helps catch future witness-shape regressions. + return nil, fmt.Errorf("unexpected covenant witness stack") + } + + return transaction, nil +} + +func (cse *covenantSignerEngine) buildQcV1SignerHandoff( + ctx context.Context, + requestID string, + signingExecutor *signingExecutor, + request covenantsigner.RouteSubmitRequest, + activeUtxo *bitcoin.UnspentTransactionOutput, + witnessScript bitcoin.Script, +) (*qcV1SignerHandoff, error) { + builder, err := cse.buildCovenantTransactionBuilder( + request, + activeUtxo, + witnessScript, + ) + if err != nil { + return nil, err + } + signature, err := signCovenantTransactionInput(ctx, signingExecutor, builder) + if err != nil { + return nil, err + } + signatureBytes, err := buildWitnessSignatureBytes(signature) + if err != nil { + return nil, err + } + + unsignedTransaction := builder.Build() + unsignedTransactionHex := "0x" + hex.EncodeToString(unsignedTransaction.Serialize(bitcoin.Standard)) + witnessScriptHex := "0x" + hex.EncodeToString(witnessScript) + signatureHex := "0x" + hex.EncodeToString(signatureBytes) + selectorWitnessItems := []string{"0x01", "0x"} + + payloadHash, err := computeQcV1SignerHandoffPayloadHash(map[string]any{ + "kind": qcV1SignerHandoffKind, + "unsignedTransactionHex": unsignedTransactionHex, + "witnessScript": witnessScriptHex, + "signerSignature": signatureHex, + "selectorWitnessItems": selectorWitnessItems, + "requiresDummy": true, + "sighashType": uint32(txscript.SigHashAll), + "destinationCommitmentHash": request.DestinationCommitmentHash, + }) + if err != nil { + return nil, err + } + + return &qcV1SignerHandoff{ + Kind: qcV1SignerHandoffKind, + SignerRequestID: requestID, + BundleID: payloadHash, + DestinationCommitmentHash: request.DestinationCommitmentHash, + PayloadHash: payloadHash, + UnsignedTransactionHex: unsignedTransactionHex, + WitnessScript: witnessScriptHex, + SignerSignature: signatureHex, + SelectorWitnessItems: selectorWitnessItems, + RequiresDummy: true, + SighashType: uint32(txscript.SigHashAll), + }, nil +} + +func (cse *covenantSignerEngine) buildCovenantTransactionBuilder( + request covenantsigner.RouteSubmitRequest, + activeUtxo *bitcoin.UnspentTransactionOutput, + witnessScript bitcoin.Script, +) (*bitcoin.TransactionBuilder, error) { + destinationScript, err := decodePrefixedHex(request.MigrationDestination.DepositScript) + if err != nil { + return nil, fmt.Errorf("migration destination deposit script is invalid") + } + destinationValue, err := toBitcoinOutputValue( + request.MigrationTransactionPlan.DestinationValueSats, + "migration destination value", + ) + if err != nil { + return nil, err + } + anchorValue, err := toBitcoinOutputValue( + request.MigrationTransactionPlan.AnchorValueSats, + "migration anchor value", + ) + if err != nil { + return nil, err + } + + builder := bitcoin.NewTransactionBuilder(cse.node.btcChain) + if err := builder.AddScriptHashInput(activeUtxo, witnessScript); err != nil { + return nil, fmt.Errorf("cannot add covenant input: %v", err) + } + if err := builder.SetInputSequence(0, request.MigrationTransactionPlan.InputSequence); err != nil { + return nil, fmt.Errorf("cannot set covenant input sequence: %v", err) + } + builder.SetLocktime(request.MigrationTransactionPlan.LockTime) + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: destinationValue, + PublicKeyScript: destinationScript, + }) + + anchorScript, err := canonicalAnchorScriptPubKey() + if err != nil { + return nil, err + } + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: anchorValue, + PublicKeyScript: anchorScript, + }) + + return builder, nil +} + +func signCovenantTransactionInput( + ctx context.Context, + signingExecutor *signingExecutor, + builder *bitcoin.TransactionBuilder, +) (*tecdsa.Signature, error) { + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + return nil, fmt.Errorf("cannot compute covenant sighash: %v", err) + } + if len(sigHashes) != 1 { + return nil, fmt.Errorf("unexpected covenant sighash count") + } + + startBlock, err := signingExecutor.getCurrentBlockFn() + if err != nil { + return nil, fmt.Errorf("cannot determine signing start block: %v", err) + } + + signatures, err := signingExecutor.signBatch(ctx, sigHashes, startBlock) + if err != nil { + return nil, fmt.Errorf("cannot sign covenant transaction: %v", err) + } + if len(signatures) != 1 { + return nil, fmt.Errorf("unexpected covenant signature count") + } + return signatures[0], nil +} + +func buildSelfV1MigrationWitness( + signature *tecdsa.Signature, + witnessScript bitcoin.Script, +) ([][]byte, error) { + signatureBytes, err := buildWitnessSignatureBytes(signature) + if err != nil { + return nil, err + } + + return [][]byte{ + signatureBytes, + {0x01}, + {}, + witnessScript, + }, nil +} + +func buildWitnessSignatureBytes(signature *tecdsa.Signature) ([]byte, error) { + if signature == nil || signature.R == nil || signature.S == nil { + return nil, fmt.Errorf("missing covenant signature") + } + + return append( + (&btcec.Signature{R: signature.R, S: signature.S}).Serialize(), + byte(txscript.SigHashAll), + ), nil +} + +func computeQcV1SignerHandoffPayloadHash(payload map[string]any) (string, error) { + // The handoff bundle ID is content-addressed using Go's stable JSON map-key + // ordering. Future non-Go custodian consumers that want to recompute this + // hash must preserve the same canonical field set and serialization rules. + rawPayload, err := json.Marshal(payload) + if err != nil { + return "", err + } + + sum := sha256.Sum256(rawPayload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func (handoff *qcV1SignerHandoff) toMap() map[string]any { + return map[string]any{ + "kind": handoff.Kind, + "signerRequestId": handoff.SignerRequestID, + "bundleId": handoff.BundleID, + "destinationCommitmentHash": handoff.DestinationCommitmentHash, + "payloadHash": handoff.PayloadHash, + "unsignedTransactionHex": handoff.UnsignedTransactionHex, + "witnessScript": handoff.WitnessScript, + "signerSignature": handoff.SignerSignature, + "selectorWitnessItems": handoff.SelectorWitnessItems, + "requiresDummy": handoff.RequiresDummy, + "sighashType": handoff.SighashType, + } +} + +func canonicalAnchorScriptPubKey() (bitcoin.Script, error) { + witnessScriptHash := bitcoin.WitnessScriptHash(bitcoin.Script{txscript.OP_TRUE}) + return bitcoin.PayToWitnessScriptHash(witnessScriptHash) +} + +func decodePrefixedHex(value string) ([]byte, error) { + return hex.DecodeString(strings.TrimPrefix(value, "0x")) +} + +func canonicalCompressedPublicKeyBytes(encoded string) ([]byte, error) { + bytes, err := decodePrefixedHex(encoded) + if err != nil { + return nil, err + } + + parsed, err := btcec.ParsePubKey(bytes, btcec.S256()) + if err != nil { + return nil, err + } + + return parsed.SerializeCompressed(), nil +} + +func toBitcoinOutputValue(value uint64, field string) (int64, error) { + if value > math.MaxInt64 { + return 0, fmt.Errorf("%s exceeds bitcoin output value range", field) + } + + return int64(value), nil +} + +func encodeScriptNumber(value uint32) ([]byte, error) { + if value == 0 { + return []byte{}, nil + } + + result := make([]byte, 0, 5) + absolute := value + for absolute > 0 { + result = append(result, byte(absolute&0xff)) + absolute >>= 8 + } + + if result[len(result)-1]&0x80 != 0 { + result = append(result, 0x00) + } + + return result, nil +} + +func failedTransition(reason covenantsigner.FailureReason, detail string) *covenantsigner.Transition { + return &covenantsigner.Transition{ + State: covenantsigner.JobStateFailed, + Reason: reason, + Detail: detail, + } +} diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go new file mode 100644 index 0000000000..c7794f98ca --- /dev/null +++ b/pkg/tbtc/covenant_signer_test.go @@ -0,0 +1,1600 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "math" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/ethereum/go-ethereum/crypto" + "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/bitcoin" + "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/covenantsigner" + "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/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" +) + +type covenantSignerMemoryDescriptor struct { + name string + directory string + content []byte +} + +func (md *covenantSignerMemoryDescriptor) Name() string { return md.name } +func (md *covenantSignerMemoryDescriptor) Directory() string { return md.directory } +func (md *covenantSignerMemoryDescriptor) Content() ([]byte, error) { + return md.content, nil +} + +type covenantSignerMemoryHandle struct { + items map[string]*covenantSignerMemoryDescriptor +} + +func newCovenantSignerMemoryHandle() *covenantSignerMemoryHandle { + return &covenantSignerMemoryHandle{items: make(map[string]*covenantSignerMemoryDescriptor)} +} + +func (h *covenantSignerMemoryHandle) key(directory, name string) string { + return directory + "/" + name +} + +func (h *covenantSignerMemoryHandle) Save(data []byte, directory, name string) error { + h.items[h.key(directory, name)] = &covenantSignerMemoryDescriptor{ + name: name, + directory: directory, + content: append([]byte{}, data...), + } + return nil +} + +func (h *covenantSignerMemoryHandle) Delete(directory, name string) error { + delete(h.items, h.key(directory, name)) + return nil +} + +func (h *covenantSignerMemoryHandle) ReadAll() (<-chan persistence.DataDescriptor, <-chan error) { + dataChan := make(chan persistence.DataDescriptor, len(h.items)) + errChan := make(chan error) + for _, item := range h.items { + dataChan <- item + } + close(dataChan) + close(errChan) + return dataChan, errChan +} + +func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { + node, bitcoinChain, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node, 0), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.SelfV1Template{ + Template: covenantsigner.TemplateSelfV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + maturityHeight := uint64(912345) + witnessScript, err := buildSelfV1WitnessScript(template, maturityHeight) + if err != nil { + t.Fatal(err) + } + witnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + activeScriptPubKey, err := bitcoin.PayToWitnessScriptHash(witnessScriptHash) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + }) + if err != nil { + t.Fatal(err) + } + + const ( + inputValueSats = uint64(1_000_000) + destinationValueSats = uint64(998_000) + anchorValueSats = uint64(330) + feeSats = uint64(1_670) + ) + + prevTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: int64(inputValueSats), + PublicKeyScript: activeScriptPubKey, + }, + }, + Locktime: 0, + } + bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + bitcoinChain.setTransactionConfirmations(prevTransaction.Hash(), 6) + + activeScriptHash := sha256.Sum256(activeScriptPubKey) + revealer := "0x2222222222222222222222222222222222222222" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + + migrationDestination := &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_self_1", + Reserve: reserve, + Epoch: 12, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + } + migrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + migrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + migrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, migrationDestination) + + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_self_1", + IdempotencyKey: "idem_self_1", + RequestType: covenantsigner.RequestTypeReconstruct, + Route: covenantsigner.TemplateSelfV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 12, + MaturityHeight: maturityHeight, + ActiveOutpoint: covenantsigner.CovenantOutpoint{TxID: "0x" + prevTransaction.Hash().Hex(bitcoin.ReversedByteOrder), Vout: 0, ScriptHash: "0x" + hex.EncodeToString(activeScriptHash[:])}, + DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, + MigrationDestination: migrationDestination, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: inputValueSats, + DestinationValueSats: destinationValueSats, + AnchorValueSats: anchorValueSats, + FeeSats: feeSats, + InputSequence: 0xfffffffd, + LockTime: uint32(maturityHeight), + }, + ArtifactSignatures: []string{"0x0708"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: false, + }, + } + applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + nil, + ) + + result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_self_ready", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusReady { + t.Fatalf("expected READY, got %s", result.Status) + } + if result.PSBTHash == "" || result.TransactionHex == "" { + t.Fatalf("expected final artifact payload, got %#v", result) + } + + transactionBytes, err := hex.DecodeString(strings.TrimPrefix(result.TransactionHex, "0x")) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{} + if err := transaction.Deserialize(transactionBytes); err != nil { + t.Fatal(err) + } + + if transaction.Locktime != uint32(maturityHeight) { + t.Fatalf("unexpected locktime: %d", transaction.Locktime) + } + if len(transaction.Inputs) != 1 { + t.Fatalf("unexpected input count: %d", len(transaction.Inputs)) + } + if transaction.Inputs[0].Sequence != 0xfffffffd { + t.Fatalf("unexpected input sequence: %x", transaction.Inputs[0].Sequence) + } + if len(transaction.Outputs) != 2 { + t.Fatalf("unexpected output count: %d", len(transaction.Outputs)) + } + if transaction.Outputs[0].Value != int64(destinationValueSats) { + t.Fatalf("unexpected destination value: %d", transaction.Outputs[0].Value) + } + if !bytes.Equal(transaction.Outputs[0].PublicKeyScript, destinationScript) { + t.Fatal("unexpected destination output script") + } + + expectedAnchorScript, err := canonicalAnchorScriptPubKey() + if err != nil { + t.Fatal(err) + } + if transaction.Outputs[1].Value != int64(anchorValueSats) { + t.Fatalf("unexpected anchor value: %d", transaction.Outputs[1].Value) + } + if !bytes.Equal(transaction.Outputs[1].PublicKeyScript, expectedAnchorScript) { + t.Fatal("unexpected anchor output script") + } + + if len(transaction.Inputs[0].Witness) != 4 { + t.Fatalf("unexpected witness item count: %d", len(transaction.Inputs[0].Witness)) + } + if !bytes.Equal(transaction.Inputs[0].Witness[1], []byte{0x01}) { + t.Fatal("missing migration selector witness item") + } + if len(transaction.Inputs[0].Witness[2]) != 0 { + t.Fatal("expected empty second selector witness item") + } + if !bytes.Equal(transaction.Inputs[0].Witness[3], witnessScript) { + t.Fatal("unexpected witness script") + } + + if result.PSBTHash != "0x"+transaction.WitnessHash().Hex(bitcoin.InternalByteOrder) { + t.Fatalf("unexpected psbtHash: %s", result.PSBTHash) + } + + signatureWithHashType := transaction.Inputs[0].Witness[0] + if len(signatureWithHashType) == 0 || signatureWithHashType[len(signatureWithHashType)-1] != byte(txscript.SigHashAll) { + t.Fatal("unexpected sighash type in witness signature") + } + + wireTransaction := wire.NewMsgTx(wire.TxVersion) + if err := wireTransaction.Deserialize(bytes.NewReader(transaction.Serialize(bitcoin.Witness))); err != nil { + t.Fatal(err) + } + + sighashBytes, err := txscript.CalcWitnessSigHash( + witnessScript, + txscript.NewTxSigHashes(wireTransaction), + txscript.SigHashAll, + wireTransaction, + 0, + int64(inputValueSats), + ) + if err != nil { + t.Fatal(err) + } + + parsedSignature, err := btcec.ParseDERSignature(signatureWithHashType[:len(signatureWithHashType)-1], btcec.S256()) + if err != nil { + t.Fatal(err) + } + if !ecdsa.Verify(walletPublicKey, sighashBytes, parsedSignature.R, parsedSignature.S) { + t.Fatal("invalid covenant signature") + } +} + +func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { + node, bitcoinChain, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node, 0), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + custodianPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x24}, 32)) + custodianPublicKey := custodianPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.QcV1Template{ + Template: covenantsigner.TemplateQcV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + CustodianPublicKey: "0x" + hex.EncodeToString(custodianPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Beta: 144, + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + maturityHeight := uint64(912345) + witnessScript, err := buildQcV1WitnessScript(template, maturityHeight) + if err != nil { + t.Fatal(err) + } + witnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + activeScriptPubKey, err := bitcoin.PayToWitnessScriptHash(witnessScriptHash) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + }) + if err != nil { + t.Fatal(err) + } + + const ( + inputValueSats = uint64(2_000_000) + destinationValueSats = uint64(1_997_500) + anchorValueSats = uint64(330) + feeSats = uint64(2_170) + ) + + prevTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: int64(inputValueSats), + PublicKeyScript: activeScriptPubKey, + }, + }, + Locktime: 0, + } + bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + bitcoinChain.setTransactionConfirmations(prevTransaction.Hash(), 6) + + activeScriptHash := sha256.Sum256(activeScriptPubKey) + revealer := "0x4444444444444444444444444444444444444444" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + + migrationDestination := &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_qc_1", + Reserve: reserve, + Epoch: 21, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusCommittedToEpoch, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + } + migrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + migrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + migrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, migrationDestination) + + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_qc_1", + IdempotencyKey: "idem_qc_1", + RequestType: covenantsigner.RequestTypeReconstruct, + Route: covenantsigner.TemplateQcV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 21, + MaturityHeight: maturityHeight, + ActiveOutpoint: covenantsigner.CovenantOutpoint{TxID: "0x" + prevTransaction.Hash().Hex(bitcoin.ReversedByteOrder), Vout: 0, ScriptHash: "0x" + hex.EncodeToString(activeScriptHash[:])}, + DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, + MigrationDestination: migrationDestination, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: inputValueSats, + DestinationValueSats: destinationValueSats, + AnchorValueSats: anchorValueSats, + FeeSats: feeSats, + InputSequence: 0xfffffffd, + LockTime: uint32(maturityHeight), + }, + ArtifactSignatures: []string{"0x090a"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: true, + }, + } + applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + custodianPrivateKey, + ) + + result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_qc_ready", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusReady { + t.Fatalf("expected READY, got %s", result.Status) + } + if result.Handoff == nil { + t.Fatal("expected handoff payload") + } + if result.TransactionHex != "" || result.PSBTHash != "" { + t.Fatalf("expected handoff-only result, got %#v", result) + } + + handoffKind, ok := result.Handoff["kind"].(string) + if !ok || handoffKind != qcV1SignerHandoffKind { + t.Fatalf("unexpected handoff kind: %#v", result.Handoff["kind"]) + } + if signerRequestID, ok := result.Handoff["signerRequestId"].(string); !ok || signerRequestID != result.RequestID { + t.Fatalf("unexpected signerRequestId: %#v", result.Handoff["signerRequestId"]) + } + if requiresDummy, ok := result.Handoff["requiresDummy"].(bool); !ok || !requiresDummy { + t.Fatalf("unexpected requiresDummy: %#v", result.Handoff["requiresDummy"]) + } + if sighashType, ok := result.Handoff["sighashType"].(uint32); !ok || sighashType != uint32(txscript.SigHashAll) { + t.Fatalf("unexpected sighashType: %#v", result.Handoff["sighashType"]) + } + + selectorWitnessItems, ok := result.Handoff["selectorWitnessItems"].([]string) + if !ok { + t.Fatalf("unexpected selector witness items type: %#v", result.Handoff["selectorWitnessItems"]) + } + if len(selectorWitnessItems) != 2 || selectorWitnessItems[0] != "0x01" || selectorWitnessItems[1] != "0x" { + t.Fatalf("unexpected selector witness items: %#v", selectorWitnessItems) + } + + unsignedTransactionHex, ok := result.Handoff["unsignedTransactionHex"].(string) + if !ok || unsignedTransactionHex == "" { + t.Fatalf("unexpected unsignedTransactionHex: %#v", result.Handoff["unsignedTransactionHex"]) + } + unsignedTransactionBytes, err := hex.DecodeString(strings.TrimPrefix(unsignedTransactionHex, "0x")) + if err != nil { + t.Fatal(err) + } + unsignedTransaction := &bitcoin.Transaction{} + if err := unsignedTransaction.Deserialize(unsignedTransactionBytes); err != nil { + t.Fatal(err) + } + + if unsignedTransaction.Locktime != uint32(maturityHeight) { + t.Fatalf("unexpected locktime: %d", unsignedTransaction.Locktime) + } + if len(unsignedTransaction.Inputs) != 1 { + t.Fatalf("unexpected input count: %d", len(unsignedTransaction.Inputs)) + } + if unsignedTransaction.Inputs[0].Sequence != 0xfffffffd { + t.Fatalf("unexpected input sequence: %x", unsignedTransaction.Inputs[0].Sequence) + } + if len(unsignedTransaction.Outputs) != 2 { + t.Fatalf("unexpected output count: %d", len(unsignedTransaction.Outputs)) + } + if unsignedTransaction.Outputs[0].Value != int64(destinationValueSats) { + t.Fatalf("unexpected destination value: %d", unsignedTransaction.Outputs[0].Value) + } + if !bytes.Equal(unsignedTransaction.Outputs[0].PublicKeyScript, destinationScript) { + t.Fatal("unexpected destination output script") + } + + expectedAnchorScript, err := canonicalAnchorScriptPubKey() + if err != nil { + t.Fatal(err) + } + if unsignedTransaction.Outputs[1].Value != int64(anchorValueSats) { + t.Fatalf("unexpected anchor value: %d", unsignedTransaction.Outputs[1].Value) + } + if !bytes.Equal(unsignedTransaction.Outputs[1].PublicKeyScript, expectedAnchorScript) { + t.Fatal("unexpected anchor output script") + } + + witnessScriptHex, ok := result.Handoff["witnessScript"].(string) + if !ok || witnessScriptHex == "" { + t.Fatalf("unexpected witnessScript: %#v", result.Handoff["witnessScript"]) + } + if witnessScriptHex != "0x"+hex.EncodeToString(witnessScript) { + t.Fatalf("unexpected witness script hex: %s", witnessScriptHex) + } + + signatureHex, ok := result.Handoff["signerSignature"].(string) + if !ok || signatureHex == "" { + t.Fatalf("unexpected signerSignature: %#v", result.Handoff["signerSignature"]) + } + signatureBytes, err := hex.DecodeString(strings.TrimPrefix(signatureHex, "0x")) + if err != nil { + t.Fatal(err) + } + if len(signatureBytes) == 0 || signatureBytes[len(signatureBytes)-1] != byte(txscript.SigHashAll) { + t.Fatal("unexpected sighash type in handoff signature") + } + + wireTransaction := wire.NewMsgTx(wire.TxVersion) + if err := wireTransaction.Deserialize(bytes.NewReader(unsignedTransaction.Serialize(bitcoin.Standard))); err != nil { + t.Fatal(err) + } + sighashBytes, err := txscript.CalcWitnessSigHash( + witnessScript, + txscript.NewTxSigHashes(wireTransaction), + txscript.SigHashAll, + wireTransaction, + 0, + int64(inputValueSats), + ) + if err != nil { + t.Fatal(err) + } + parsedSignature, err := btcec.ParseDERSignature(signatureBytes[:len(signatureBytes)-1], btcec.S256()) + if err != nil { + t.Fatal(err) + } + if !ecdsa.Verify(walletPublicKey, sighashBytes, parsedSignature.R, parsedSignature.S) { + t.Fatal("invalid qc_v1 signer handoff signature") + } + + expectedPayloadHash, err := computeQcV1SignerHandoffPayloadHash(map[string]any{ + "kind": qcV1SignerHandoffKind, + "unsignedTransactionHex": unsignedTransactionHex, + "witnessScript": witnessScriptHex, + "signerSignature": signatureHex, + "selectorWitnessItems": selectorWitnessItems, + "requiresDummy": true, + "sighashType": uint32(txscript.SigHashAll), + "destinationCommitmentHash": request.DestinationCommitmentHash, + }) + if err != nil { + t.Fatal(err) + } + + if payloadHash, ok := result.Handoff["payloadHash"].(string); !ok || payloadHash != expectedPayloadHash { + t.Fatalf("unexpected payloadHash: %#v", result.Handoff["payloadHash"]) + } + if bundleID, ok := result.Handoff["bundleId"].(string); !ok || bundleID != expectedPayloadHash { + t.Fatalf("unexpected bundleId: %#v", result.Handoff["bundleId"]) + } + if destinationCommitmentHash, ok := result.Handoff["destinationCommitmentHash"].(string); !ok || destinationCommitmentHash != request.DestinationCommitmentHash { + t.Fatalf("unexpected destinationCommitmentHash: %#v", result.Handoff["destinationCommitmentHash"]) + } +} + +func TestCovenantSignerEngine_SubmitQcV1RejectsInvalidBeta(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node, 0), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + custodianPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x24}, 32)) + custodianPublicKey := custodianPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.QcV1Template{ + Template: covenantsigner.TemplateQcV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + CustodianPublicKey: "0x" + hex.EncodeToString(custodianPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Beta: 500, + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + }) + if err != nil { + t.Fatal(err) + } + + revealer := "0x4444444444444444444444444444444444444444" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_qc_bad_beta", + IdempotencyKey: "idem_qc_bad_beta", + RequestType: covenantsigner.RequestTypeReconstruct, + Route: covenantsigner.TemplateQcV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 21, + MaturityHeight: 500, + ActiveOutpoint: covenantsigner.CovenantOutpoint{ + TxID: "0x" + strings.Repeat("11", 32), + }, + MigrationDestination: &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_qc_bad_beta", + Reserve: reserve, + Epoch: 21, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + }, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: 2_000_000, + DestinationValueSats: 1_997_500, + AnchorValueSats: 330, + FeeSats: 2_170, + InputSequence: 0xfffffffd, + LockTime: 500, + }, + ArtifactSignatures: []string{"0x090a"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: true, + }, + } + request.MigrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + request.MigrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) + request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash + applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + custodianPrivateKey, + ) + + result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_qc_bad_beta", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusFailed { + t.Fatalf("expected FAILED, got %s", result.Status) + } + if result.Reason != covenantsigner.ReasonInvalidInput { + t.Fatalf("unexpected failure reason: %s", result.Reason) + } + if !strings.Contains(result.Detail, "qc_v1 beta must be below maturity height") { + t.Fatalf("unexpected failure detail: %s", result.Detail) + } +} + +func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) { + node, bitcoinChain, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node, 0), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + custodianPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x24}, 32)) + custodianPublicKey := custodianPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.QcV1Template{ + Template: covenantsigner.TemplateQcV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + CustodianPublicKey: "0x" + hex.EncodeToString(custodianPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Beta: 144, + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + maturityHeight := uint64(912345) + witnessScript, err := buildQcV1WitnessScript(template, maturityHeight) + if err != nil { + t.Fatal(err) + } + witnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + activeScriptPubKey, err := bitcoin.PayToWitnessScriptHash(witnessScriptHash) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + }) + if err != nil { + t.Fatal(err) + } + + prevTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 2_000_000, + PublicKeyScript: activeScriptPubKey, + }, + }, + Locktime: 0, + } + bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + bitcoinChain.setTransactionConfirmations(prevTransaction.Hash(), 6) + + revealer := "0x4444444444444444444444444444444444444444" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + migrationDestination := &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_qc_bad_script_hash", + Reserve: reserve, + Epoch: 21, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusCommittedToEpoch, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + } + migrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + migrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + migrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, migrationDestination) + + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_qc_bad_script_hash", + IdempotencyKey: "idem_qc_bad_script_hash", + RequestType: covenantsigner.RequestTypeReconstruct, + Route: covenantsigner.TemplateQcV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 21, + MaturityHeight: maturityHeight, + ActiveOutpoint: covenantsigner.CovenantOutpoint{TxID: "0x" + prevTransaction.Hash().Hex(bitcoin.ReversedByteOrder), Vout: 0, ScriptHash: "0x" + strings.Repeat("aa", 32)}, + DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, + MigrationDestination: migrationDestination, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: 2_000_000, + DestinationValueSats: 1_997_500, + AnchorValueSats: 330, + FeeSats: 2_170, + InputSequence: 0xfffffffd, + LockTime: uint32(maturityHeight), + }, + ArtifactSignatures: []string{"0x090a"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: true, + }, + } + applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + custodianPrivateKey, + ) + + result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_qc_bad_script_hash", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusFailed { + t.Fatalf("expected FAILED, got %s", result.Status) + } + if result.Reason != covenantsigner.ReasonInvalidInput { + t.Fatalf("unexpected failure reason: %s", result.Reason) + } + if !strings.Contains(result.Detail, "active outpoint script hash does not match qc_v1 template") { + t.Fatalf("unexpected failure detail: %s", result.Detail) + } +} + +func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node, 0), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.SelfV1Template{ + Template: covenantsigner.TemplateSelfV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + }) + if err != nil { + t.Fatal(err) + } + + revealer := "0x2222222222222222222222222222222222222222" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_self_zero", + IdempotencyKey: "idem_self_zero", + RequestType: covenantsigner.RequestTypeReconstruct, + Route: covenantsigner.TemplateSelfV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 12, + MaturityHeight: 0, + ActiveOutpoint: covenantsigner.CovenantOutpoint{ + TxID: "0x" + strings.Repeat("11", 32), + }, + MigrationDestination: &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_self_zero", + Reserve: reserve, + Epoch: 12, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + }, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: 1_000_000, + DestinationValueSats: 998_000, + AnchorValueSats: 330, + FeeSats: 1_670, + InputSequence: 0xfffffffd, + LockTime: 0, + }, + ArtifactSignatures: []string{"0x0708"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: false, + }, + } + request.MigrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + request.MigrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) + request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash + applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + nil, + ) + + result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_self_zero", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusFailed { + t.Fatalf("expected FAILED, got %s", result.Status) + } + if result.Reason != covenantsigner.ReasonInvalidInput { + t.Fatalf("unexpected failure reason: %s", result.Reason) + } + if !strings.Contains(result.Detail, "maturity height must be greater than zero") { + t.Fatalf("unexpected failure detail: %s", result.Detail) + } +} + +func TestValidateMigrationOutputValues_RejectsValuesExceedingInt64(t *testing.T) { + err := validateMigrationOutputValues(covenantsigner.RouteSubmitRequest{ + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + DestinationValueSats: uint64(math.MaxInt64) + 1, + AnchorValueSats: 330, + }, + }) + if err == nil { + t.Fatal("expected output value validation error") + } + if !strings.Contains(err.Error(), "migration destination value exceeds bitcoin output value range") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCovenantSignerEngine_EnsureActiveOutpointFinalityRejectsUnconfirmed(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 0) + + err := (&covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 6, + }).ensureActiveOutpointFinality(activeTransactionHash) + if err == nil || !strings.Contains(err.Error(), "active outpoint transaction must have at least 6 confirmations") { + t.Fatalf("expected confirmation error, got %v", err) + } +} + +func setupCovenantSignerTestNode( + t *testing.T, +) (*node, *localBitcoinChain, *ecdsa.PublicKey) { + t.Helper() + + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + operatorPrivateKey, operatorPublicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatal(err) + } + + localChain := ConnectWithKey(operatorPrivateKey) + localProvider := local.ConnectWithKey(operatorPublicKey) + bitcoinChain := newLocalBitcoinChain() + + operatorAddress, err := localChain.Signing().PublicKeyToAddress(operatorPublicKey) + if err != nil { + t.Fatal(err) + } + + var operators []chain.Address + for i := 0; i < groupParameters.GroupSize; i++ { + operators = append(operators, operatorAddress) + } + + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(groupParameters.GroupSize) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + + signers := make([]*signer, len(testData)) + for i := range testData { + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[i]) + signers[i] = &signer{ + wallet: wallet{ + publicKey: privateKeyShare.PublicKey(), + signingGroupOperators: operators, + }, + signingGroupMemberIndex: group.MemberIndex(i + 1), + privateKeyShare: privateKeyShare, + } + } + + walletPublicKeyHash := bitcoin.PublicKeyHash(signers[0].wallet.publicKey) + walletID, err := localChain.CalculateWalletID(signers[0].wallet.publicKey) + if err != nil { + t.Fatal(err) + } + membersIDsHash := sha256.Sum256([]byte("covenant-signer-test-members")) + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: walletID, + MembersIDsHash: membersIDsHash, + State: StateLive, + }, + ) + + node, err := newNode( + groupParameters, + localChain, + bitcoinChain, + localProvider, + createMockKeyStorePersistence(t, signers...), + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{}, + ) + if err != nil { + t.Fatal(err) + } + + executor, ok, err := node.getSigningExecutor(signers[0].wallet.publicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + executor.signingAttemptsLimit *= 8 + + return node, bitcoinChain, signers[0].wallet.publicKey +} + +func testMigrationExtraData(revealer string) string { + return "0x" + hex.EncodeToString([]byte("AC_MIGRATEV1")) + strings.TrimPrefix(strings.ToLower(revealer), "0x") +} + +func testDepositScriptHash(t *testing.T, depositScript bitcoin.Script) string { + t.Helper() + + sum := sha256.Sum256(depositScript) + return "0x" + hex.EncodeToString(sum[:]) +} + +type testDestinationCommitmentPayload struct { + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DepositScriptHash string `json:"depositScriptHash"` + MigrationExtraData string `json:"migrationExtraData"` +} + +func testDestinationCommitmentHash( + t *testing.T, + reservation *covenantsigner.MigrationDestinationReservation, +) string { + t.Helper() + + payload, err := json.Marshal(testDestinationCommitmentPayload{ + Reserve: strings.ToLower(reservation.Reserve), + Epoch: reservation.Epoch, + Route: string(reservation.Route), + Revealer: strings.ToLower(reservation.Revealer), + Vault: strings.ToLower(reservation.Vault), + Network: strings.TrimSpace(reservation.Network), + DepositScriptHash: strings.ToLower(reservation.DepositScriptHash), + MigrationExtraData: strings.ToLower(reservation.MigrationExtraData), + }) + if err != nil { + t.Fatal(err) + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]) +} + +type testMigrationTransactionPlanCommitmentPayload struct { + PlanVersion uint32 `json:"planVersion"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + InputValueSats uint64 `json:"inputValueSats"` + DestinationValueSats uint64 `json:"destinationValueSats"` + AnchorValueSats uint64 `json:"anchorValueSats"` + FeeSats uint64 `json:"feeSats"` + InputSequence uint32 `json:"inputSequence"` + LockTime uint32 `json:"lockTime"` +} + +func testMigrationTransactionPlanCommitmentHash( + t *testing.T, + request covenantsigner.RouteSubmitRequest, + plan *covenantsigner.MigrationTransactionPlan, +) string { + t.Helper() + + payload, err := json.Marshal(testMigrationTransactionPlanCommitmentPayload{ + PlanVersion: plan.PlanVersion, + Reserve: strings.ToLower(request.Reserve), + Epoch: request.Epoch, + ActiveOutpointTxID: strings.ToLower(request.ActiveOutpoint.TxID), + ActiveOutpointVout: request.ActiveOutpoint.Vout, + DestinationCommitmentHash: strings.ToLower(request.DestinationCommitmentHash), + InputValueSats: plan.InputValueSats, + DestinationValueSats: plan.DestinationValueSats, + AnchorValueSats: plan.AnchorValueSats, + FeeSats: plan.FeeSats, + InputSequence: plan.InputSequence, + LockTime: plan.LockTime, + }) + if err != nil { + t.Fatal(err) + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]) +} + +var testArtifactApprovalTypeHash = crypto.Keccak256Hash([]byte( + "ArtifactApproval(" + + "uint8 approvalVersion," + + "bytes32 route," + + "bytes32 scriptTemplateId," + + "bytes32 destinationCommitmentHash," + + "bytes32 planCommitmentHash)", +)) + +func testArtifactApprovalDigest( + t *testing.T, + payload covenantsigner.ArtifactApprovalPayload, +) []byte { + t.Helper() + + decodeBytes32 := func(name string, value string) [32]byte { + t.Helper() + + rawValue, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + t.Fatalf("cannot decode %s: %v", name, err) + } + if len(rawValue) != 32 { + t.Fatalf("expected %s to be 32 bytes, got %d", name, len(rawValue)) + } + + var decoded [32]byte + copy(decoded[:], rawValue) + return decoded + } + + encodeUint32 := func(value uint32) [32]byte { + var encoded [32]byte + binary.BigEndian.PutUint32(encoded[28:], value) + return encoded + } + + keccakTemplateIdentifier := func(value covenantsigner.TemplateID) [32]byte { + hash := crypto.Keccak256Hash([]byte(value)) + var encoded [32]byte + copy(encoded[:], hash.Bytes()) + return encoded + } + + destinationCommitmentHash := decodeBytes32( + "destinationCommitmentHash", + payload.DestinationCommitmentHash, + ) + planCommitmentHash := decodeBytes32( + "planCommitmentHash", + payload.PlanCommitmentHash, + ) + approvalVersionWord := encodeUint32(payload.ApprovalVersion) + routeIdentifier := keccakTemplateIdentifier(payload.Route) + scriptTemplateIdentifier := keccakTemplateIdentifier(payload.ScriptTemplateID) + + encoded := make([]byte, 32*6) + copy(encoded[0:32], testArtifactApprovalTypeHash.Bytes()) + copy(encoded[32:64], approvalVersionWord[:]) + copy(encoded[64:96], routeIdentifier[:]) + copy(encoded[96:128], scriptTemplateIdentifier[:]) + copy(encoded[128:160], destinationCommitmentHash[:]) + copy(encoded[160:192], planCommitmentHash[:]) + + digest := crypto.Keccak256Hash(encoded) + return digest.Bytes() +} + +func testSignArtifactApproval( + t *testing.T, + privateKey *btcec.PrivateKey, + payload covenantsigner.ArtifactApprovalPayload, +) string { + t.Helper() + + signature, err := privateKey.Sign(testArtifactApprovalDigest(t, payload)) + if err != nil { + t.Fatal(err) + } + + return "0x" + hex.EncodeToString(signature.Serialize()) +} + +func applyTestArtifactApprovals( + t *testing.T, + node *node, + walletPublicKey *ecdsa.PublicKey, + request *covenantsigner.RouteSubmitRequest, + depositorPrivateKey *btcec.PrivateKey, + custodianPrivateKey *btcec.PrivateKey, +) { + t.Helper() + + payload := covenantsigner.ArtifactApprovalPayload{ + ApprovalVersion: 1, + Route: request.Route, + ScriptTemplateID: request.Route, + DestinationCommitmentHash: request.DestinationCommitmentHash, + PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, + } + + approvals := []covenantsigner.ArtifactRoleApproval{ + { + Role: covenantsigner.ArtifactApprovalRoleDepositor, + Signature: testSignArtifactApproval(t, depositorPrivateKey, payload), + }, + } + + if request.Route == covenantsigner.TemplateQcV1 { + approvals = []covenantsigner.ArtifactRoleApproval{ + { + Role: covenantsigner.ArtifactApprovalRoleDepositor, + Signature: testSignArtifactApproval(t, depositorPrivateKey, payload), + }, + { + Role: covenantsigner.ArtifactApprovalRoleCustodian, + Signature: testSignArtifactApproval(t, custodianPrivateKey, payload), + }, + } + } + + request.ArtifactApprovals = &covenantsigner.ArtifactApprovalEnvelope{ + Payload: payload, + Approvals: approvals, + } + + executor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected node to control wallet signers") + } + + startBlock, err := executor.getCurrentBlockFn() + if err != nil { + t.Fatal(err) + } + + signerApproval, err := executor.issueSignerApprovalCertificate( + context.Background(), + testArtifactApprovalDigest(t, payload), + startBlock, + ) + if err != nil { + t.Fatal(err) + } + request.SignerApproval = signerApproval + request.ArtifactSignatures = make([]string, 0, len(approvals)+1) + for _, approval := range approvals { + request.ArtifactSignatures = append(request.ArtifactSignatures, approval.Signature) + } + request.ArtifactSignatures = append( + request.ArtifactSignatures, + signerApproval.Signature, + ) +} + +func applyTestMigrationTransactionPlanCommitment( + t *testing.T, + request *covenantsigner.RouteSubmitRequest, +) { + t.Helper() + + if request.MigrationTransactionPlan == nil { + return + } + + request.MigrationTransactionPlan.PlanVersion = 1 + request.MigrationTransactionPlan.PlanCommitmentHash = testMigrationTransactionPlanCommitmentHash( + t, + *request, + request.MigrationTransactionPlan, + ) +} + +func TestCovenantSignerEngine_OnPollReturnsNoTransition(t *testing.T) { + transition, err := (&covenantSignerEngine{}).OnPoll( + context.Background(), + &covenantsigner.Job{}, + ) + if err != nil { + t.Fatalf("expected nil error from OnPoll, got %v", err) + } + if transition != nil { + t.Fatalf("expected no transition from OnPoll, got %#v", transition) + } +} + +func TestCovenantSignerEngine_SubmitRejectsUnsupportedRoute(t *testing.T) { + transition, err := (&covenantSignerEngine{}).OnSubmit( + context.Background(), + &covenantsigner.Job{ + Route: covenantsigner.TemplateID("unsupported_route"), + }, + ) + if err != nil { + t.Fatalf("expected nil error from OnSubmit unsupported route, got %v", err) + } + if transition == nil { + t.Fatal("expected failed transition for unsupported route") + } + if transition.State != covenantsigner.JobStateFailed { + t.Fatalf("expected failed state, got %s", transition.State) + } + if transition.Reason != covenantsigner.ReasonInvalidInput { + t.Fatalf("expected invalid-input reason, got %s", transition.Reason) + } + if !strings.Contains(transition.Detail, "unsupported covenant route") { + t.Fatalf("expected unsupported route detail, got %q", transition.Detail) + } +} + +func TestNewCovenantSignerEngine_DefaultMinConfirmations(t *testing.T) { + node, _, _ := setupCovenantSignerTestNode(t) + + engine := newCovenantSignerEngine(node, 0) + + cse, ok := engine.(*covenantSignerEngine) + if !ok { + t.Fatal("expected engine to be *covenantSignerEngine") + } + + if cse.minimumActiveOutpointConfirmations != 6 { + t.Fatalf( + "expected default minimum confirmations to be 6, got %d", + cse.minimumActiveOutpointConfirmations, + ) + } +} + +func TestNewCovenantSignerEngine_ExplicitMinConfirmations(t *testing.T) { + node, _, _ := setupCovenantSignerTestNode(t) + + engine := newCovenantSignerEngine(node, 3) + + cse, ok := engine.(*covenantSignerEngine) + if !ok { + t.Fatal("expected engine to be *covenantSignerEngine") + } + + if cse.minimumActiveOutpointConfirmations != 3 { + t.Fatalf( + "expected minimum confirmations to be 3, got %d", + cse.minimumActiveOutpointConfirmations, + ) + } +} + +func TestEnsureActiveOutpointFinality_RejectsBelowDefaultThreshold(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 5) + + cse := &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 6, + } + + err := cse.ensureActiveOutpointFinality(activeTransactionHash) + if err == nil { + t.Fatal("expected finality error for 5 confirmations with threshold 6") + } + if !strings.Contains(err.Error(), "at least 6 confirmations") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestEnsureActiveOutpointFinality_AcceptsAtDefaultThreshold(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 6) + + cse := &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 6, + } + + err := cse.ensureActiveOutpointFinality(activeTransactionHash) + if err != nil { + t.Fatalf("expected no error for 6 confirmations with threshold 6, got %v", err) + } +} + +func TestEnsureActiveOutpointFinality_RejectsBelowCustomThreshold(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 2) + + cse := &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 3, + } + + err := cse.ensureActiveOutpointFinality(activeTransactionHash) + if err == nil { + t.Fatal("expected finality error for 2 confirmations with threshold 3") + } + if !strings.Contains(err.Error(), "at least 3 confirmations") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestEnsureActiveOutpointFinality_AcceptsAboveCustomThreshold(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 10) + + cse := &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 3, + } + + err := cse.ensureActiveOutpointFinality(activeTransactionHash) + if err != nil { + t.Fatalf("expected no error for 10 confirmations with threshold 3, got %v", err) + } +} + +func TestComputeQcV1SignerHandoffPayloadHash_DeterministicKeyOrdering(t *testing.T) { + payload := map[string]any{ + "kind": qcV1SignerHandoffKind, + "unsignedTransactionHex": "0xdeadbeef", + "witnessScript": "0xcafebabe", + "signerSignature": "0x0102030405", + "selectorWitnessItems": []string{"0x01", "0x"}, + "requiresDummy": true, + "sighashType": uint32(1), + "destinationCommitmentHash": "0xabcdef1234567890", + } + + // Verify the hash matches a pinned expected value. If this test + // breaks, it means the serialization or hashing behavior changed + // and downstream consumers relying on content-addressed bundle + // IDs will be affected. + expectedHash := "0x2785f99f276b0d56710fcdd76fa22cb7081018b847b7b8b9ba85ecd8e4c0189c" + + hash, err := computeQcV1SignerHandoffPayloadHash(payload) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if hash != expectedHash { + t.Fatalf("expected hash %s, got %s", expectedHash, hash) + } + + // Verify idempotency: calling the function twice with the same + // map must produce the same hash. + hash2, err := computeQcV1SignerHandoffPayloadHash(payload) + if err != nil { + t.Fatalf("expected nil error on second call, got %v", err) + } + if hash != hash2 { + t.Fatalf("expected idempotent hash, got %s and %s", hash, hash2) + } + + // Verify json.Marshal produces alphabetically ordered keys. + // This is the Go encoding/json guarantee (since Go 1.12) that + // the payload hash computation depends on. + rawJSON, err := json.Marshal(payload) + if err != nil { + t.Fatalf("expected nil error from json.Marshal, got %v", err) + } + expectedJSON := `{` + + `"destinationCommitmentHash":"0xabcdef1234567890",` + + `"kind":"qc_v1_signer_handoff_v1",` + + `"requiresDummy":true,` + + `"selectorWitnessItems":["0x01","0x"],` + + `"sighashType":1,` + + `"signerSignature":"0x0102030405",` + + `"unsignedTransactionHex":"0xdeadbeef",` + + `"witnessScript":"0xcafebabe"` + + `}` + if string(rawJSON) != expectedJSON { + t.Fatalf( + "expected alphabetically ordered JSON:\n%s\ngot:\n%s", + expectedJSON, + rawJSON, + ) + } +} diff --git a/pkg/tbtc/dkg_loop.go b/pkg/tbtc/dkg_loop.go index 4b7955abc9..bcd02e02a9 100644 --- a/pkg/tbtc/dkg_loop.go +++ b/pkg/tbtc/dkg_loop.go @@ -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/node.go b/pkg/tbtc/node.go index f8f40b9f7c..b6b0dc15bf 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -394,6 +394,7 @@ func (n *node) getSigningExecutor( } executor := newSigningExecutor( + n.chain, signers, broadcastChannel, membershipValidator, @@ -1429,6 +1430,8 @@ func withCancelOnBlock( block uint64, waitForBlockFn waitForBlockFn, ) (context.Context, context.CancelFunc) { + // #nosec G118 -- cancelBlockCtx is returned to the caller and also invoked + // by the waiter goroutine when the target block is reached. blockCtx, cancelBlockCtx := context.WithCancel(ctx) go func() { diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go new file mode 100644 index 0000000000..acf891a22e --- /dev/null +++ b/pkg/tbtc/signer_approval_certificate.go @@ -0,0 +1,280 @@ +package tbtc + +import ( + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "sort" + "strings" + + "github.com/btcsuite/btcd/btcec" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" + "github.com/keep-network/keep-core/pkg/internal/canonicaljson" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +const ( + signerApprovalCertificateVersion uint32 = 1 + signerApprovalCertificateSignatureAlgorithm = "tecdsa-secp256k1" + signerApprovalCertificateSignerSetDomain = "covenant-signer-set-v1:" +) + +type signerApprovalCertificateSignerSetPayload struct { + WalletID string `json:"walletId"` + WalletPublicKey string `json:"walletPublicKey"` + MembersIDsHash string `json:"membersIdsHash"` + HonestThreshold int `json:"honestThreshold"` +} + +func (se *signingExecutor) issueSignerApprovalCertificate( + ctx context.Context, + approvalDigest []byte, + startBlock uint64, +) (*covenantsigner.SignerApprovalCertificate, error) { + if len(approvalDigest) != sha256.Size { + return nil, fmt.Errorf( + "approval digest must be exactly %d bytes", + sha256.Size, + ) + } + + wallet := se.wallet() + walletChainData, err := se.chain.GetWallet(bitcoin.PublicKeyHash(wallet.publicKey)) + if err != nil { + return nil, fmt.Errorf( + "cannot get on-chain wallet data for signer approval certificate: %w", + err, + ) + } + + signature, activityReport, endBlock, err := se.sign( + ctx, + new(big.Int).SetBytes(approvalDigest), + startBlock, + ) + if err != nil { + return nil, err + } + + return buildSignerApprovalCertificate( + wallet, + walletChainData, + se.groupParameters, + approvalDigest, + signature, + activityReport, + endBlock, + ) +} + +func buildSignerApprovalCertificate( + wallet wallet, + walletChainData *WalletChainData, + groupParameters *GroupParameters, + approvalDigest []byte, + signature *tecdsa.Signature, + activityReport *signingActivityReport, + endBlock uint64, +) (*covenantsigner.SignerApprovalCertificate, error) { + if len(approvalDigest) != sha256.Size { + return nil, fmt.Errorf( + "approval digest must be exactly %d bytes", + sha256.Size, + ) + } + if groupParameters == nil { + return nil, fmt.Errorf("group parameters are required") + } + if walletChainData == nil { + return nil, fmt.Errorf("wallet chain data is required") + } + if signature == nil || signature.R == nil || signature.S == nil { + return nil, fmt.Errorf("threshold signature is required") + } + + // signerApproval.walletPublicKey intentionally uses uncompressed SEC1 + // encoding (65 bytes, 0x04 prefix) to match wallet-ID derivation and + // signer-set hash payloads across the signer approval pipeline. + walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + if err != nil { + return nil, err + } + + signerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + wallet.publicKey, + walletChainData, + groupParameters, + ) + if err != nil { + return nil, err + } + + signatureBytes := (&btcec.Signature{ + R: signature.R, + S: signature.S, + }).Serialize() + + certificate := &covenantsigner.SignerApprovalCertificate{ + CertificateVersion: signerApprovalCertificateVersion, + SignatureAlgorithm: signerApprovalCertificateSignatureAlgorithm, + WalletPublicKey: "0x" + hex.EncodeToString(walletPublicKeyBytes), + SignerSetHash: signerSetHash, + ApprovalDigest: "0x" + hex.EncodeToString(approvalDigest), + Signature: "0x" + hex.EncodeToString(signatureBytes), + } + certificate.EndBlock = &endBlock + + if activityReport != nil { + certificate.ActiveMembers = normalizeSignerApprovalMemberIndexes( + activityReport.activeMembers, + ) + certificate.InactiveMembers = normalizeSignerApprovalMemberIndexes( + activityReport.inactiveMembers, + ) + } + + return certificate, nil +} + +func computeSignerApprovalCertificateSignerSetHash( + walletPublicKey *ecdsa.PublicKey, + walletChainData *WalletChainData, + groupParameters *GroupParameters, +) (string, error) { + if groupParameters == nil { + return "", fmt.Errorf("group parameters are required") + } + if walletChainData == nil { + return "", fmt.Errorf("wallet chain data is required") + } + if walletChainData.EcdsaWalletID == ([32]byte{}) { + return "", fmt.Errorf("wallet chain data must include wallet ID") + } + if walletChainData.MembersIDsHash == ([32]byte{}) { + return "", fmt.Errorf("wallet chain data must include members IDs hash") + } + + // Keep signer-set payload key encoding aligned with certificate issuance: + // uncompressed SEC1 (65-byte, 0x04-prefixed) wallet public key. + walletPublicKeyBytes, err := marshalPublicKey(walletPublicKey) + if err != nil { + return "", err + } + + payload, err := canonicaljson.Marshal(signerApprovalCertificateSignerSetPayload{ + WalletID: "0x" + hex.EncodeToString(walletChainData.EcdsaWalletID[:]), + WalletPublicKey: "0x" + hex.EncodeToString(walletPublicKeyBytes), + MembersIDsHash: "0x" + hex.EncodeToString(walletChainData.MembersIDsHash[:]), + HonestThreshold: groupParameters.HonestThreshold, + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256( + append([]byte(signerApprovalCertificateSignerSetDomain), payload...), + ) + + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func verifySignerApprovalCertificate( + certificate *covenantsigner.SignerApprovalCertificate, + expectedSignerSetHash string, +) error { + if certificate == nil { + return fmt.Errorf("certificate is required") + } + if certificate.CertificateVersion != signerApprovalCertificateVersion { + return fmt.Errorf("unsupported certificate version: %d", certificate.CertificateVersion) + } + if certificate.SignatureAlgorithm != signerApprovalCertificateSignatureAlgorithm { + return fmt.Errorf("unsupported signature algorithm: %s", certificate.SignatureAlgorithm) + } + if strings.TrimSpace(expectedSignerSetHash) == "" { + return fmt.Errorf("expected signer set hash must not be empty") + } + if strings.ToLower(expectedSignerSetHash) != strings.ToLower(certificate.SignerSetHash) { + return fmt.Errorf("signer set hash does not match the expected signer set") + } + + approvalDigest, err := decodeSignerApprovalCertificateHex( + certificate.ApprovalDigest, + sha256.Size, + ) + if err != nil { + return fmt.Errorf("invalid approval digest: %w", err) + } + signatureBytes, err := decodeSignerApprovalCertificateHex( + certificate.Signature, + 0, + ) + if err != nil { + return fmt.Errorf("invalid threshold signature: %w", err) + } + walletPublicKeyBytes, err := decodeSignerApprovalCertificateHex( + certificate.WalletPublicKey, + 0, + ) + if err != nil { + return fmt.Errorf("invalid wallet public key: %w", err) + } + + walletPublicKey := unmarshalPublicKey(walletPublicKeyBytes) + if walletPublicKey == nil || walletPublicKey.X == nil || walletPublicKey.Y == nil { + return fmt.Errorf("wallet public key is not a valid uncompressed secp256k1 key") + } + + parsedSignature, err := btcec.ParseDERSignature(signatureBytes, btcec.S256()) + if err != nil { + return fmt.Errorf("cannot parse threshold signature: %w", err) + } + + if !ecdsa.Verify(walletPublicKey, approvalDigest, parsedSignature.R, parsedSignature.S) { + return fmt.Errorf("threshold signature does not verify against wallet public key") + } + + return nil +} + +func decodeSignerApprovalCertificateHex( + value string, + expectedBytes int, +) ([]byte, error) { + normalized := strings.TrimSpace(value) + if !strings.HasPrefix(normalized, "0x") { + return nil, fmt.Errorf("value must be 0x-prefixed") + } + + decoded, err := hex.DecodeString(strings.TrimPrefix(normalized, "0x")) + if err != nil { + return nil, err + } + if expectedBytes > 0 && len(decoded) != expectedBytes { + return nil, fmt.Errorf( + "value must be exactly %d bytes, got %d", + expectedBytes, + len(decoded), + ) + } + + return decoded, nil +} + +func normalizeSignerApprovalMemberIndexes( + memberIndexes []group.MemberIndex, +) []uint32 { + normalized := make([]uint32, len(memberIndexes)) + for i, memberIndex := range memberIndexes { + normalized[i] = uint32(memberIndex) + } + sort.Slice(normalized, func(i, j int) bool { + return normalized[i] < normalized[j] + }) + return normalized +} diff --git a/pkg/tbtc/signer_approval_certificate_fuzz_test.go b/pkg/tbtc/signer_approval_certificate_fuzz_test.go new file mode 100644 index 0000000000..ae7d3f6498 --- /dev/null +++ b/pkg/tbtc/signer_approval_certificate_fuzz_test.go @@ -0,0 +1,16 @@ +package tbtc + +import "testing" + +func FuzzDecodeSignerApprovalCertificateHex_NoPanic(f *testing.F) { + f.Add("0x") + f.Add("0x00") + f.Add("0x" + "11") + f.Add("deadbeef") + f.Add("0xzz") + + f.Fuzz(func(t *testing.T, value string) { + _, _ = decodeSignerApprovalCertificateHex(value, 0) + _, _ = decodeSignerApprovalCertificateHex(value, 32) + }) +} diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go new file mode 100644 index 0000000000..60ece03cb7 --- /dev/null +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -0,0 +1,534 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" +) + +func validStructuredSignerApprovalVerificationRequest( + t *testing.T, + node *node, + walletPublicKey *ecdsa.PublicKey, + route covenantsigner.TemplateID, +) covenantsigner.RouteSubmitRequest { + t.Helper() + + executor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + + startBlock, err := executor.getCurrentBlockFn() + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes( + btcec.S256(), + bytes.Repeat([]byte{0xaa}, 32), + ) + + request := covenantsigner.RouteSubmitRequest{ + RequestType: covenantsigner.RequestTypeReconstruct, + Route: route, + ArtifactApprovals: &covenantsigner.ArtifactApprovalEnvelope{ + Payload: covenantsigner.ArtifactApprovalPayload{ + ApprovalVersion: 1, + Route: route, + ScriptTemplateID: route, + DestinationCommitmentHash: "0x" + strings.Repeat("11", 32), + PlanCommitmentHash: "0x" + strings.Repeat("22", 32), + }, + }, + } + + switch route { + case covenantsigner.TemplateSelfV1: + templateJSON, err := json.Marshal(&covenantsigner.SelfV1Template{ + Template: covenantsigner.TemplateSelfV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPrivateKey.PubKey().SerializeCompressed()), + SignerPublicKey: "0x" + hex.EncodeToString((*btcec.PublicKey)(walletPublicKey).SerializeCompressed()), + Delta2: 4320, + }) + if err != nil { + t.Fatal(err) + } + request.ScriptTemplate = templateJSON + request.ArtifactApprovals.Approvals = []covenantsigner.ArtifactRoleApproval{ + { + Role: covenantsigner.ArtifactApprovalRoleDepositor, + Signature: testSignArtifactApproval( + t, + depositorPrivateKey, + request.ArtifactApprovals.Payload, + ), + }, + } + case covenantsigner.TemplateQcV1: + custodianPrivateKey, _ := btcec.PrivKeyFromBytes( + btcec.S256(), + bytes.Repeat([]byte{0xbb}, 32), + ) + templateJSON, err := json.Marshal(&covenantsigner.QcV1Template{ + Template: covenantsigner.TemplateQcV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPrivateKey.PubKey().SerializeCompressed()), + CustodianPublicKey: "0x" + hex.EncodeToString(custodianPrivateKey.PubKey().SerializeCompressed()), + SignerPublicKey: "0x" + hex.EncodeToString((*btcec.PublicKey)(walletPublicKey).SerializeCompressed()), + Beta: 144, + Delta2: 4320, + }) + if err != nil { + t.Fatal(err) + } + request.ScriptTemplate = templateJSON + request.ArtifactApprovals.Approvals = []covenantsigner.ArtifactRoleApproval{ + { + Role: covenantsigner.ArtifactApprovalRoleDepositor, + Signature: testSignArtifactApproval( + t, + depositorPrivateKey, + request.ArtifactApprovals.Payload, + ), + }, + { + Role: covenantsigner.ArtifactApprovalRoleCustodian, + Signature: testSignArtifactApproval( + t, + custodianPrivateKey, + request.ArtifactApprovals.Payload, + ), + }, + } + default: + t.Fatalf("unsupported route %s", route) + } + + certificate, err := executor.issueSignerApprovalCertificate( + context.Background(), + testArtifactApprovalDigest(t, request.ArtifactApprovals.Payload), + startBlock, + ) + if err != nil { + t.Fatal(err) + } + request.SignerApproval = certificate + + return request +} + +func TestSigningExecutorCanIssueSignerApprovalCertificateForArbitraryDigest(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + + executor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + + startBlock, err := executor.getCurrentBlockFn() + if err != nil { + t.Fatal(err) + } + + approvalDigest := sha256.Sum256( + []byte("psbt-covenant-signer-approval-certificate-spike"), + ) + + certificate, err := executor.issueSignerApprovalCertificate( + context.Background(), + approvalDigest[:], + startBlock, + ) + if err != nil { + t.Fatal(err) + } + + walletChainData, err := executor.chain.GetWallet( + bitcoin.PublicKeyHash(executor.wallet().publicKey), + ) + if err != nil { + t.Fatal(err) + } + + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + executor.wallet().publicKey, + walletChainData, + executor.groupParameters, + ) + if err != nil { + t.Fatal(err) + } + if err := verifySignerApprovalCertificate(certificate, expectedSignerSetHash); err != nil { + t.Fatalf("expected certificate verification to succeed: %v", err) + } + + expectedDigest := "0x" + hex.EncodeToString(approvalDigest[:]) + if certificate.ApprovalDigest != expectedDigest { + t.Fatalf( + "unexpected approval digest\nexpected: %s\nactual: %s", + expectedDigest, + certificate.ApprovalDigest, + ) + } + if certificate.SignerSetHash != expectedSignerSetHash { + t.Fatalf( + "unexpected signer set hash\nexpected: %s\nactual: %s", + expectedSignerSetHash, + certificate.SignerSetHash, + ) + } + if len(certificate.ActiveMembers) < executor.groupParameters.HonestThreshold { + t.Fatalf( + "expected at least honest threshold active members, got %v", + certificate.ActiveMembers, + ) + } + if certificate.EndBlock == nil || *certificate.EndBlock < startBlock { + t.Fatalf( + "expected end block [%v] to be >= start block [%v]", + certificate.EndBlock, + startBlock, + ) + } +} + +func TestSignerApprovalCertificateVerificationRejectsTamperedDigest(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + + executor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + + startBlock, err := executor.getCurrentBlockFn() + if err != nil { + t.Fatal(err) + } + + approvalDigest := sha256.Sum256([]byte("psbt-covenant-signer-approval-certificate")) + certificate, err := executor.issueSignerApprovalCertificate( + context.Background(), + approvalDigest[:], + startBlock, + ) + if err != nil { + t.Fatal(err) + } + + walletChainData, err := executor.chain.GetWallet( + bitcoin.PublicKeyHash(executor.wallet().publicKey), + ) + if err != nil { + t.Fatal(err) + } + + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + executor.wallet().publicKey, + walletChainData, + executor.groupParameters, + ) + if err != nil { + t.Fatal(err) + } + + tampered := *certificate + tamperedDigest := sha256.Sum256([]byte("tampered")) + tampered.ApprovalDigest = "0x" + hex.EncodeToString(tamperedDigest[:]) + if err := verifySignerApprovalCertificate(&tampered, expectedSignerSetHash); err == nil { + t.Fatal("expected tampered approval digest to fail verification") + } +} + +func TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThreshold(t *testing.T) { + _, _, walletPublicKey := setupCovenantSignerTestNode(t) + + baseWalletChainData := &WalletChainData{ + EcdsaWalletID: sha256.Sum256([]byte("wallet-id-base")), + MembersIDsHash: sha256.Sum256([]byte("members-hash-base")), + } + baseGroupParameters := &GroupParameters{ + GroupSize: 3, + GroupQuorum: 2, + HonestThreshold: 2, + } + + baseHash, err := computeSignerApprovalCertificateSignerSetHash( + walletPublicKey, + baseWalletChainData, + baseGroupParameters, + ) + if err != nil { + t.Fatal(err) + } + + changedMembersHash, err := computeSignerApprovalCertificateSignerSetHash( + walletPublicKey, + &WalletChainData{ + EcdsaWalletID: baseWalletChainData.EcdsaWalletID, + MembersIDsHash: sha256.Sum256([]byte("members-hash-changed")), + }, + baseGroupParameters, + ) + if err != nil { + t.Fatal(err) + } + if changedMembersHash == baseHash { + t.Fatal("expected signer set hash to change when members IDs hash changes") + } + + changedWalletIDHash, err := computeSignerApprovalCertificateSignerSetHash( + walletPublicKey, + &WalletChainData{ + EcdsaWalletID: sha256.Sum256([]byte("wallet-id-changed")), + MembersIDsHash: baseWalletChainData.MembersIDsHash, + }, + baseGroupParameters, + ) + if err != nil { + t.Fatal(err) + } + if changedWalletIDHash == baseHash { + t.Fatal("expected signer set hash to change when wallet ID changes") + } + + thresholdChangedHash, err := computeSignerApprovalCertificateSignerSetHash( + walletPublicKey, + baseWalletChainData, + &GroupParameters{ + GroupSize: 3, + GroupQuorum: 2, + HonestThreshold: 3, + }, + ) + if err != nil { + t.Fatal(err) + } + if thresholdChangedHash == baseHash { + t.Fatal("expected signer set hash to change when honest threshold changes") + } +} + +func TestCovenantSignerEngineVerifySignerApprovalAcceptsValidCertificate(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + if err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request); err != nil { + t.Fatalf("expected signer approval verification to succeed: %v", err) + } +} + +func TestCovenantSignerEngineVerifySignerApprovalRejectsWalletPublicKeyMismatch(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + request.SignerApproval.WalletPublicKey = "0x04" + strings.Repeat("55", 64) + + err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval.walletPublicKey must match request.scriptTemplate.signerPublicKey", + ) { + t.Fatalf("expected wallet public key mismatch error, got %v", err) + } +} + +func TestCovenantSignerEngineVerifySignerApprovalRejectsMissingCertificate(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + request.SignerApproval = nil + + err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval is required for signer approval verification", + ) { + t.Fatalf("expected missing signer approval error, got %v", err) + } +} + +func TestCovenantSignerEngineVerifySignerApprovalRejectsApprovalDigestMismatch(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + request.SignerApproval.ApprovalDigest = "0x" + strings.Repeat("11", 32) + + err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval.approvalDigest must match request.artifactApprovals.payload", + ) { + t.Fatalf("expected signer approval digest mismatch error, got %v", err) + } +} + +func TestCovenantSignerEngineVerifySignerApprovalRejectsMissingOnChainWallet(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + localChain, ok := node.chain.(*localChain) + if !ok { + t.Fatal("expected local chain implementation") + } + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + localChain.walletsMutex.Lock() + delete(localChain.wallets, walletPublicKeyHash) + localChain.walletsMutex.Unlock() + + err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval.walletPublicKey must resolve to a registered on-chain wallet", + ) { + t.Fatalf("expected missing wallet input error, got %v", err) + } +} + +func TestVerifySignerApprovalCertificateRejectsEmptyExpectedSignerSetHash(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + err := verifySignerApprovalCertificate(request.SignerApproval, "") + if err == nil || !strings.Contains(err.Error(), "expected signer set hash must not be empty") { + t.Fatalf("expected empty signer set hash error, got %v", err) + } +} + +func TestVerifySignerApprovalCertificateRejectsSignerSetMismatch(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + err := verifySignerApprovalCertificate( + request.SignerApproval, + "0x"+strings.Repeat("ab", 32), + ) + if err == nil || !strings.Contains(err.Error(), "signer set hash does not match the expected signer set") { + t.Fatalf("expected signer set mismatch error, got %v", err) + } +} + +func TestVerifySignerApprovalCertificateRejectsMalformedDERSignature(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + certificate := *request.SignerApproval + certificate.Signature = "0xdeadbeef" + + walletExecutor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + walletChainData, err := walletExecutor.chain.GetWallet(bitcoin.PublicKeyHash(walletPublicKey)) + if err != nil { + t.Fatal(err) + } + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + walletPublicKey, + walletChainData, + walletExecutor.groupParameters, + ) + if err != nil { + t.Fatal(err) + } + + err = verifySignerApprovalCertificate(&certificate, expectedSignerSetHash) + if err == nil || !strings.Contains(err.Error(), "cannot parse threshold signature") { + t.Fatalf("expected malformed DER signature error, got %v", err) + } +} + +func TestVerifySignerApprovalCertificateRejectsMalformedWalletPublicKey(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + certificate := *request.SignerApproval + certificate.WalletPublicKey = "0x02" + strings.Repeat("11", 32) + + walletExecutor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + walletChainData, err := walletExecutor.chain.GetWallet(bitcoin.PublicKeyHash(walletPublicKey)) + if err != nil { + t.Fatal(err) + } + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + walletPublicKey, + walletChainData, + walletExecutor.groupParameters, + ) + if err != nil { + t.Fatal(err) + } + + err = verifySignerApprovalCertificate(&certificate, expectedSignerSetHash) + if err == nil || !strings.Contains(err.Error(), "wallet public key is not a valid uncompressed secp256k1 key") { + t.Fatalf("expected malformed wallet public key error, got %v", err) + } +} diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 346b6b0446..40bf947d3b 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -45,6 +45,7 @@ var errSigningExecutorBusy = fmt.Errorf("signing executor is busy") type signingExecutor struct { lock *semaphore.Weighted + chain Chain signers []*signer broadcastChannel net.BroadcastChannel membershipValidator *group.MembershipValidator @@ -70,6 +71,7 @@ type signingExecutor struct { } func newSigningExecutor( + chain Chain, signers []*signer, broadcastChannel net.BroadcastChannel, membershipValidator *group.MembershipValidator, @@ -81,6 +83,7 @@ func newSigningExecutor( ) *signingExecutor { return &signingExecutor{ lock: semaphore.NewWeighted(1), + chain: chain, signers: signers, broadcastChannel: broadcastChannel, membershipValidator: membershipValidator, diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 62b226aed6..70a9756e98 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -7,6 +7,7 @@ import ( "time" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/ipfs/go-log" @@ -69,7 +70,8 @@ type Config struct { // Initialize kicks off the TBTC by initializing internal state, ensuring // preconditions like staking are met, and then kicking off the internal TBTC -// implementation. Returns an error if this failed. +// implementation. Returns the covenant signer engine bound to the initialized +// node together with an error if initialization failed. func Initialize( ctx context.Context, chain Chain, @@ -82,7 +84,8 @@ func Initialize( config Config, clientInfo *clientinfo.Registry, perfMetrics *clientinfo.PerformanceMetrics, -) error { + minActiveOutpointConfirmations uint, +) (covenantsigner.Engine, error) { groupParameters := &GroupParameters{ GroupSize: 100, GroupQuorum: 90, @@ -101,12 +104,12 @@ func Initialize( config, ) if err != nil { - return fmt.Errorf("cannot set up TBTC node: [%v]", err) + return nil, fmt.Errorf("cannot set up TBTC node: [%v]", err) } err = node.runCoordinationLayer(ctx) if err != nil { - return fmt.Errorf("cannot run coordination layer: [%w]", err) + return nil, fmt.Errorf("cannot run coordination layer: [%w]", err) } deduplicator := newDeduplicator() @@ -161,7 +164,7 @@ func Initialize( ), ) if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "could not set up sortition pool monitoring: [%v]", err, ) @@ -323,7 +326,7 @@ func Initialize( }() }) - return nil + return newCovenantSignerEngine(node, minActiveOutpointConfirmations), nil } // enoughPreParamsInPoolPolicy is a policy that enforces the sufficient size diff --git a/pkg/tbtcpg/internal/test/marshaling.go b/pkg/tbtcpg/internal/test/marshaling.go index 2dd72dbaa0..91c390df6e 100644 --- a/pkg/tbtcpg/internal/test/marshaling.go +++ b/pkg/tbtcpg/internal/test/marshaling.go @@ -3,6 +3,7 @@ package test import ( "encoding/hex" "encoding/json" + "errors" "fmt" "github.com/keep-network/keep-core/pkg/tbtcpg" "math/big" @@ -273,7 +274,7 @@ func (psts *ProposeSweepTestScenario) UnmarshalJSON(data []byte) error { // Unmarshal expected error if len(unmarshaled.ExpectedErr) > 0 { - psts.ExpectedErr = fmt.Errorf(unmarshaled.ExpectedErr) + psts.ExpectedErr = errors.New(unmarshaled.ExpectedErr) } return nil diff --git a/test/config.json b/test/config.json index 9b10178109..8e3662cd9a 100644 --- a/test/config.json +++ b/test/config.json @@ -39,6 +39,10 @@ "NetworkMetricsTick": "43s", "EthereumMetricsTick": "1m27s" }, + "CovenantSigner": { + "Port": 9702, + "RequireApprovalTrustRoots": true + }, "Maintainer": { "BitcoinDifficulty": { "Enabled": true, diff --git a/test/config.toml b/test/config.toml index 9801f00f0a..44836f6e9a 100644 --- a/test/config.toml +++ b/test/config.toml @@ -35,6 +35,10 @@ Port = 3498 NetworkMetricsTick = "43s" EthereumMetricsTick = "1m27s" +[covenantsigner] +Port = 9702 +RequireApprovalTrustRoots = true + [maintainer.BitcoinDifficulty] Enabled = true DisableProxy = true diff --git a/test/config.yaml b/test/config.yaml index abddcb3fde..dce648d86c 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -30,6 +30,9 @@ ClientInfo: Port: 3498 NetworkMetricsTick: "43s" EthereumMetricsTick: "1m27s" +CovenantSigner: + Port: 9702 + RequireApprovalTrustRoots: true Maintainer: BitcoinDifficulty: Enabled: true