diff --git a/arkrpc/ark.pb.go b/arkrpc/ark.pb.go index edf637ad1..f83603ffb 100644 --- a/arkrpc/ark.pb.go +++ b/arkrpc/ark.pb.go @@ -72,13 +72,6 @@ type GetInfoResponse struct { BoardingExitDelay uint32 `protobuf:"varint,5,opt,name=boarding_exit_delay,json=boardingExitDelay,proto3" json:"boarding_exit_delay,omitempty"` // The minimum CSV delay (blocks) for VTXO unilateral exit paths. VtxoExitDelay uint32 `protobuf:"varint,6,opt,name=vtxo_exit_delay,json=vtxoExitDelay,proto3" json:"vtxo_exit_delay,omitempty"` - // The output script that clients must use for the penalty output in - // forfeit transactions. - ForfeitScript []byte `protobuf:"bytes,7,opt,name=forfeit_script,json=forfeitScript,proto3" json:"forfeit_script,omitempty"` - // The operator key used in VTXO sweep paths. - SweepKey []byte `protobuf:"bytes,8,opt,name=sweep_key,json=sweepKey,proto3" json:"sweep_key,omitempty"` - // The batch-wide absolute timelock (blocks) for sweep transactions. - SweepDelay uint32 `protobuf:"varint,9,opt,name=sweep_delay,json=sweepDelay,proto3" json:"sweep_delay,omitempty"` // The minimum output value (satoshis) enforced for boarding/funding // flows. DustLimit int64 `protobuf:"varint,10,opt,name=dust_limit,json=dustLimit,proto3" json:"dust_limit,omitempty"` @@ -187,27 +180,6 @@ func (x *GetInfoResponse) GetVtxoExitDelay() uint32 { return 0 } -func (x *GetInfoResponse) GetForfeitScript() []byte { - if x != nil { - return x.ForfeitScript - } - return nil -} - -func (x *GetInfoResponse) GetSweepKey() []byte { - if x != nil { - return x.SweepKey - } - return nil -} - -func (x *GetInfoResponse) GetSweepDelay() uint32 { - if x != nil { - return x.SweepDelay - } - return 0 -} - func (x *GetInfoResponse) GetDustLimit() int64 { if x != nil { return x.DustLimit @@ -449,18 +421,14 @@ var File_ark_proto protoreflect.FileDescriptor const file_ark_proto_rawDesc = "" + "\n" + "\tark.proto\x12\x06arkrpc\"\x10\n" + - "\x0eGetInfoRequest\"\xac\x05\n" + + "\x0eGetInfoRequest\"\xc7\x04\n" + "\x0fGetInfoResponse\x12\x18\n" + "\aversion\x18\x01 \x01(\tR\aversion\x12\x16\n" + "\x06pubkey\x18\x02 \x01(\fR\x06pubkey\x12\x18\n" + "\anetwork\x18\x03 \x01(\tR\anetwork\x12!\n" + "\fblock_height\x18\x04 \x01(\rR\vblockHeight\x12.\n" + "\x13boarding_exit_delay\x18\x05 \x01(\rR\x11boardingExitDelay\x12&\n" + - "\x0fvtxo_exit_delay\x18\x06 \x01(\rR\rvtxoExitDelay\x12%\n" + - "\x0eforfeit_script\x18\a \x01(\fR\rforfeitScript\x12\x1b\n" + - "\tsweep_key\x18\b \x01(\fR\bsweepKey\x12\x1f\n" + - "\vsweep_delay\x18\t \x01(\rR\n" + - "sweepDelay\x12\x1d\n" + + "\x0fvtxo_exit_delay\x18\x06 \x01(\rR\rvtxoExitDelay\x12\x1d\n" + "\n" + "dust_limit\x18\n" + " \x01(\x03R\tdustLimit\x12.\n" + diff --git a/arkrpc/ark.proto b/arkrpc/ark.proto index 239f5dd0a..d3312d0d7 100644 --- a/arkrpc/ark.proto +++ b/arkrpc/ark.proto @@ -37,15 +37,9 @@ message GetInfoResponse { // The minimum CSV delay (blocks) for VTXO unilateral exit paths. uint32 vtxo_exit_delay = 6; - // The output script that clients must use for the penalty output in - // forfeit transactions. - bytes forfeit_script = 7; - - // The operator key used in VTXO sweep paths. - bytes sweep_key = 8; - - // The batch-wide absolute timelock (blocks) for sweep transactions. - uint32 sweep_delay = 9; + // Field numbers 7, 8 and 9 are unused: the forfeit penalty key, sweep + // key and delay are delivered per-round in round.v1.ClientBatchInfo + // instead. // The minimum output value (satoshis) enforced for boarding/funding // flows. diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index 1de554abd..5dd195236 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -889,14 +889,6 @@ type ServerInfo struct { BoardingExitDelay uint32 `protobuf:"varint,2,opt,name=boarding_exit_delay,json=boardingExitDelay,proto3" json:"boarding_exit_delay,omitempty"` // vtxo_exit_delay is the minimum CSV delay for VTXO outputs. VtxoExitDelay uint32 `protobuf:"varint,3,opt,name=vtxo_exit_delay,json=vtxoExitDelay,proto3" json:"vtxo_exit_delay,omitempty"` - // forfeit_script is the raw serialized scriptPubKey for the penalty - // output clients must use in forfeit transactions. - ForfeitScript []byte `protobuf:"bytes,4,opt,name=forfeit_script,json=forfeitScript,proto3" json:"forfeit_script,omitempty"` - // sweep_key is the compressed SEC-encoded public key used in VTXO - // sweep paths when present. - SweepKey []byte `protobuf:"bytes,5,opt,name=sweep_key,json=sweepKey,proto3" json:"sweep_key,omitempty"` - // sweep_delay is the batch-wide absolute timelock in blocks. - SweepDelay uint32 `protobuf:"varint,6,opt,name=sweep_delay,json=sweepDelay,proto3" json:"sweep_delay,omitempty"` // dust_limit is the minimum output value accepted by the operator. DustLimit uint64 `protobuf:"varint,7,opt,name=dust_limit,json=dustLimit,proto3" json:"dust_limit,omitempty"` // min_boarding_amount is the smallest boarding amount the operator @@ -967,27 +959,6 @@ func (x *ServerInfo) GetVtxoExitDelay() uint32 { return 0 } -func (x *ServerInfo) GetForfeitScript() []byte { - if x != nil { - return x.ForfeitScript - } - return nil -} - -func (x *ServerInfo) GetSweepKey() []byte { - if x != nil { - return x.SweepKey - } - return nil -} - -func (x *ServerInfo) GetSweepDelay() uint32 { - if x != nil { - return x.SweepDelay - } - return 0 -} - func (x *ServerInfo) GetDustLimit() uint64 { if x != nil { return x.DustLimit @@ -7846,16 +7817,12 @@ const file_daemon_proto_rawDesc = "" + "\x0fidentity_pubkey\x18\n" + " \x01(\tR\x0eidentityPubkey\x126\n" + "\vserver_info\x18\v \x01(\v2\x15.daemonrpc.ServerInfoR\n" + - "serverInfo\"\xe3\x03\n" + + "serverInfo\"\xfe\x02\n" + "\n" + "ServerInfo\x12'\n" + "\x0foperator_pubkey\x18\x01 \x01(\fR\x0eoperatorPubkey\x12.\n" + "\x13boarding_exit_delay\x18\x02 \x01(\rR\x11boardingExitDelay\x12&\n" + - "\x0fvtxo_exit_delay\x18\x03 \x01(\rR\rvtxoExitDelay\x12%\n" + - "\x0eforfeit_script\x18\x04 \x01(\fR\rforfeitScript\x12\x1b\n" + - "\tsweep_key\x18\x05 \x01(\fR\bsweepKey\x12\x1f\n" + - "\vsweep_delay\x18\x06 \x01(\rR\n" + - "sweepDelay\x12\x1d\n" + + "\x0fvtxo_exit_delay\x18\x03 \x01(\rR\rvtxoExitDelay\x12\x1d\n" + "\n" + "dust_limit\x18\a \x01(\x04R\tdustLimit\x12.\n" + "\x13min_boarding_amount\x18\b \x01(\x04R\x11minBoardingAmount\x12.\n" + diff --git a/daemonrpc/daemon.proto b/daemonrpc/daemon.proto index d689c282c..8a3700d74 100644 --- a/daemonrpc/daemon.proto +++ b/daemonrpc/daemon.proto @@ -321,16 +321,9 @@ message ServerInfo { // vtxo_exit_delay is the minimum CSV delay for VTXO outputs. uint32 vtxo_exit_delay = 3; - // forfeit_script is the raw serialized scriptPubKey for the penalty - // output clients must use in forfeit transactions. - bytes forfeit_script = 4; - - // sweep_key is the compressed SEC-encoded public key used in VTXO - // sweep paths when present. - bytes sweep_key = 5; - - // sweep_delay is the batch-wide absolute timelock in blocks. - uint32 sweep_delay = 6; + // Field numbers 4, 5 and 6 are unused: the forfeit penalty key, sweep + // key and delay are delivered per-round in round.v1.ClientBatchInfo + // instead. // dust_limit is the minimum output value accepted by the operator. uint64 dust_limit = 7; diff --git a/darepod/rpc_fees_test.go b/darepod/rpc_fees_test.go index ff2fc8e74..448b2b1b0 100644 --- a/darepod/rpc_fees_test.go +++ b/darepod/rpc_fees_test.go @@ -351,7 +351,6 @@ func newFakeOperatorResponse(t *testing.T, return &arkrpc.GetInfoResponse{ Pubkey: priv.PubKey().SerializeCompressed(), VtxoExitDelay: 144, - SweepDelay: 1008, MinOperatorFee: minOperatorFee, } } diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index 4e83a8aac..aae2a24a0 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -594,12 +594,14 @@ func (r *RPCServer) GetInfo(ctx context.Context, _ *daemonrpc.GetInfoRequest) ( return resp, nil } + // The forfeit penalty key, sweep key and delay are no longer + // global operator terms; they are delivered per round in the + // round batch info, so they are not surfaced on this + // daemon-level ServerInfo snapshot. resp.ServerInfo = &daemonrpc.ServerInfo{ OperatorPubkey: terms.PubKey.SerializeCompressed(), BoardingExitDelay: terms.BoardingExitDelay, VtxoExitDelay: terms.VTXOExitDelay, - ForfeitScript: terms.ForfeitScript, - SweepDelay: terms.SweepDelay, DustLimit: uint64(terms.DustLimit), MinBoardingAmount: uint64(terms.MinBoardingAmount), MaxBoardingAmount: uint64(terms.MaxBoardingAmount), @@ -607,11 +609,6 @@ func (r *RPCServer) GetInfo(ctx context.Context, _ *daemonrpc.GetInfoRequest) ( MinOperatorFee: uint64(terms.MinOperatorFee), MinConfirmations: terms.MinConfirmations, } - - if terms.SweepKey != nil { - resp.ServerInfo.SweepKey = - terms.SweepKey.SerializeCompressed() - } } return resp, nil diff --git a/darepod/rpc_server_test.go b/darepod/rpc_server_test.go index 5b9e0c34f..077099792 100644 --- a/darepod/rpc_server_test.go +++ b/darepod/rpc_server_test.go @@ -931,9 +931,6 @@ func TestGetInfoIncludesServerInfo(t *testing.T) { operatorPriv, err := btcec.NewPrivateKey() require.NoError(t, err) - sweepPriv, err := btcec.NewPrivateKey() - require.NoError(t, err) - server := &Server{ cfg: &Config{ Network: "regtest", @@ -948,9 +945,6 @@ func TestGetInfoIncludesServerInfo(t *testing.T) { PubKey: operatorPriv.PubKey(), BoardingExitDelay: 144, VTXOExitDelay: 288, - ForfeitScript: []byte{0x51, 0x20, 0x01}, - SweepKey: sweepPriv.PubKey(), - SweepDelay: 432, DustLimit: btcutil.Amount(546), MinBoardingAmount: btcutil.Amount(10_000), MaxBoardingAmount: btcutil.Amount(500_000), @@ -972,14 +966,6 @@ func TestGetInfoIncludesServerInfo(t *testing.T) { ) require.Equal(t, uint32(144), resp.ServerInfo.BoardingExitDelay) require.Equal(t, uint32(288), resp.ServerInfo.VtxoExitDelay) - require.Equal( - t, []byte{0x51, 0x20, 0x01}, resp.ServerInfo.ForfeitScript, - ) - require.Equal( - t, sweepPriv.PubKey().SerializeCompressed(), - resp.ServerInfo.SweepKey, - ) - require.Equal(t, uint32(432), resp.ServerInfo.SweepDelay) require.Equal(t, uint64(546), resp.ServerInfo.DustLimit) require.Equal(t, uint64(10_000), resp.ServerInfo.MinBoardingAmount, @@ -1023,8 +1009,6 @@ func TestGetInfoConcurrentOperatorTermsAccess(t *testing.T) { PubKey: operatorPriv.PubKey(), BoardingExitDelay: 100 + i, VTXOExitDelay: 200 + i, - ForfeitScript: []byte{0x51, byte(i)}, - SweepDelay: 300 + i, DustLimit: btcutil.Amount(546), MinBoardingAmount: btcutil.Amount(10_000), MaxBoardingAmount: btcutil.Amount(500_000), diff --git a/darepod/server.go b/darepod/server.go index 2dda24b60..6a9420e42 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -3965,21 +3965,13 @@ func (s *Server) fetchOperatorTerms(ctx context.Context) (*types.OperatorTerms, return nil, fmt.Errorf("parse operator pubkey: %w", err) } - var sweepKey *btcec.PublicKey - if len(resp.SweepKey) > 0 { - sweepKey, err = btcec.ParsePubKey(resp.SweepKey) - if err != nil { - return nil, fmt.Errorf("parse sweep key: %w", err) - } - } - + // The forfeit penalty key, sweep key and sweep delay are no longer + // global operator terms; they are delivered per round in the batch + // info, so GetInfo no longer carries them. terms := &types.OperatorTerms{ PubKey: pubKey, BoardingExitDelay: resp.BoardingExitDelay, VTXOExitDelay: resp.VtxoExitDelay, - ForfeitScript: resp.ForfeitScript, - SweepKey: sweepKey, - SweepDelay: resp.SweepDelay, DustLimit: btcutil.Amount(resp.DustLimit), MinBoardingAmount: btcutil.Amount(resp.MinBoardingAmount), MaxBoardingAmount: btcutil.Amount(resp.MaxBoardingAmount), diff --git a/lib/types/boarding.go b/lib/types/boarding.go index d480983e6..1860a0965 100644 --- a/lib/types/boarding.go +++ b/lib/types/boarding.go @@ -42,17 +42,6 @@ type OperatorTerms struct { // a VTXO that has been forfeit or spent. VTXOExitDelay uint32 - // ForfeitScript is the output script that clients must use for the - // penalty output in forfeit transactions. This allows the server to - // claim forfeited funds. - ForfeitScript []byte - - // SweepKey is the operator key used in VTXT sweep paths. - SweepKey *btcec.PublicKey - - // SweepDelay is the batch-wide absolute timelock (blocks). - SweepDelay uint32 - // DustLimit enforces minimum output value for boarding/funding flows. DustLimit btcutil.Amount @@ -394,8 +383,8 @@ type BatchOutputInfo struct { // signing sessions for this batch output. SignerKey *btcec.PublicKey - // Tree is the VTXO tree for this batch output. - // Tree contains SweepKey, SweepDelay, and PrevOut. + // Tree is the VTXO tree for this batch output. The tree embeds the + // per-round sweep key, sweep delay, and PrevOut. Tree *tree.Tree } diff --git a/round/actor.go b/round/actor.go index 75e257338..3d0aa6ae1 100644 --- a/round/actor.go +++ b/round/actor.go @@ -371,11 +371,10 @@ func NewRoundClientActor(cfg *RoundClientConfig) fn.Result[*RoundClientActor] { OwnedScriptChecker: cfg.OwnedScriptChecker, } - if err := ValidateDelayParameters( - cfg.OperatorTerms.SweepDelay, cfg.OperatorTerms.VTXOExitDelay, - ); err != nil { - return fn.Err[*RoundClientActor](err) - } + // The sweep delay is no longer a global operator term: it is delivered + // per round in the batch info, so the sweep-vs-exit-delay security + // check runs per round in CommitmentTxReceivedState rather than once at + // actor construction. if cfg.TimeoutActor == nil { return fn.Err[*RoundClientActor]( diff --git a/round/actor_harness_test.go b/round/actor_harness_test.go index e0ac3536f..c48026793 100644 --- a/round/actor_harness_test.go +++ b/round/actor_harness_test.go @@ -565,8 +565,6 @@ func newActorTestHarness(t *testing.T) *actorTestHarness { PubKey: operatorPubKey, BoardingExitDelay: 144, VTXOExitDelay: 144, - SweepKey: operatorPubKey, - SweepDelay: 1008, MinConfirmations: 1, } diff --git a/round/events.go b/round/events.go index 1f04a177d..8017944b8 100644 --- a/round/events.go +++ b/round/events.go @@ -168,6 +168,41 @@ type CommitmentTxBuilt struct { // (e.g., MaxTreeNodes limit) and passed through to // roundpb.TreeFromProto during FromProto. TreeOpts []roundpb.TreeFromProtoOption + + // TreeCosignKey is the operator's MuSig2 cosigner key for this round's + // VTXO output tree, derived fresh per round by the server. The client + // aggregates and validates the tree against this key instead of the + // operator's global GetInfo key, so an operator key rotation does not + // change the tree key the client must agree on. Nil when talking to a + // server that predates this field; callers fall back to the global + // operator key. + TreeCosignKey *btcec.PublicKey + + // ConnectorOperatorKey is the operator key this round used to build its + // connector tree. The client reconstructs the connector tree from it + // instead of the global operator key. Nil when talking to an older + // server; callers fall back to the global operator key. + ConnectorOperatorKey *btcec.PublicKey + + // SweepKey is the operator sweep key for this round's VTXO-tree sweep + // leaf, delivered per round so the client agrees with the server on the + // sweep branch even across an operator key rotation. It replaces the + // global GetInfo sweep key entirely; a well-formed round always carries + // it. + SweepKey *btcec.PublicKey + + // SweepDelay is this round's batch-wide absolute-timelock in blocks for + // the VTXO-tree sweep leaf, delivered per round alongside SweepKey. It + // replaces the global GetInfo sweep delay and drives batch-expiry + // computation for VTXOs created in this round. + SweepDelay uint32 + + // ForfeitKey is the operator's dedicated forfeit penalty key for this + // round, delivered per round so the client builds the forfeit-tx + // penalty output (a BIP-86 key-spend to this key) the server expects + // even across an operator key rotation. It replaces the global GetInfo + // forfeit script entirely; a well-formed round always carries it. + ForfeitKey *btcec.PublicKey } func (e *CommitmentTxBuilt) clientEventSealed() {} diff --git a/round/from_proto.go b/round/from_proto.go index 425339197..3ef77a8b1 100644 --- a/round/from_proto.go +++ b/round/from_proto.go @@ -272,6 +272,47 @@ func (e *CommitmentTxBuilt) FromProto(p proto.Message) error { } } + // Parse this round's signing keys when present. A server that predates + // these fields leaves them empty; the FSM then falls back to the global + // operator key for tree validation and connector reconstruction. + if len(pb.TreeCosignKey) > 0 { + key, keyErr := btcec.ParsePubKey(pb.TreeCosignKey) + if keyErr != nil { + return fmt.Errorf("tree_cosign_key: %w", keyErr) + } + e.TreeCosignKey = key + } + if len(pb.ConnectorOperatorKey) > 0 { + key, keyErr := btcec.ParsePubKey(pb.ConnectorOperatorKey) + if keyErr != nil { + return fmt.Errorf("connector_operator_key: %w", keyErr) + } + e.ConnectorOperatorKey = key + } + + // Parse this round's sweep key and delay. These are delivered per round + // (replacing the removed global GetInfo sweep terms), so the FSM uses + // them to validate the VTXO-tree sweep branch and compute batch expiry. + if len(pb.SweepKey) > 0 { + key, keyErr := btcec.ParsePubKey(pb.SweepKey) + if keyErr != nil { + return fmt.Errorf("sweep_key: %w", keyErr) + } + e.SweepKey = key + } + e.SweepDelay = pb.SweepDelay + + // Parse this round's forfeit penalty key. It is delivered per round + // (replacing the removed global GetInfo forfeit script), so the FSM + // derives the forfeit-tx penalty output script from it. + if len(pb.ForfeitKey) > 0 { + key, keyErr := btcec.ParsePubKey(pb.ForfeitKey) + if keyErr != nil { + return fmt.Errorf("forfeit_key: %w", keyErr) + } + e.ForfeitKey = key + } + return nil } diff --git a/round/from_proto_test.go b/round/from_proto_test.go index a1753e115..aa628803b 100644 --- a/round/from_proto_test.go +++ b/round/from_proto_test.go @@ -273,3 +273,67 @@ func TestFromProtoInvalidRoundID(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "invalid round_id length") } + +// testRoundKey returns a deterministic valid public key for the given seed +// byte, used to exercise the round-delivered signing-key fields. +func testRoundKey(t *testing.T, seed byte) *btcec.PublicKey { + t.Helper() + + var buf [32]byte + buf[31] = seed + _, pub := btcec.PrivKeyFromBytes(buf[:]) + + return pub +} + +// TestCommitmentTxBuiltFromProtoSigningKeys verifies that FromProto parses +// the per-round tree-cosign and connector operator keys, leaves them nil when +// the server omits them (older-server fallback path), and rejects a malformed +// key. +func TestCommitmentTxBuiltFromProtoSigningKeys(t *testing.T) { + t.Parallel() + + roundID := [16]byte{ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + } + treeKey := testRoundKey(t, 0x02) + connKey := testRoundKey(t, 0x03) + + // Both keys present: parsed onto the event. + pb := &roundpb.ClientBatchInfo{ + RoundId: roundID[:], + BatchPsbt: validPSBTBytes(t), + TreeCosignKey: treeKey.SerializeCompressed(), + ConnectorOperatorKey: connKey.SerializeCompressed(), + } + var got CommitmentTxBuilt + require.NoError(t, got.FromProto(pb)) + require.NotNil(t, got.TreeCosignKey) + require.True(t, treeKey.IsEqual(got.TreeCosignKey)) + require.NotNil(t, got.ConnectorOperatorKey) + require.True(t, connKey.IsEqual(got.ConnectorOperatorKey)) + + // Keys absent: left nil so the FSM falls back to the global operator + // key (wire compatibility with a server that predates the fields). + pbEmpty := &roundpb.ClientBatchInfo{ + RoundId: roundID[:], + BatchPsbt: validPSBTBytes(t), + } + var gotEmpty CommitmentTxBuilt + require.NoError(t, gotEmpty.FromProto(pbEmpty)) + require.Nil(t, gotEmpty.TreeCosignKey) + require.Nil(t, gotEmpty.ConnectorOperatorKey) + + // Malformed key: rejected rather than silently dropped. + pbBad := &roundpb.ClientBatchInfo{ + RoundId: roundID[:], + BatchPsbt: validPSBTBytes(t), + TreeCosignKey: []byte{ + 0x00, + 0x01, + 0x02, + }, + } + var gotBad CommitmentTxBuilt + require.Error(t, gotBad.FromProto(pbBad)) +} diff --git a/round/harness_test.go b/round/harness_test.go index ef20f0369..ced95f896 100644 --- a/round/harness_test.go +++ b/round/harness_test.go @@ -367,6 +367,11 @@ type boardingTestHarness struct { operatorPrivKey *btcec.PrivateKey operatorPubKey *btcec.PublicKey + // forfeitPubKey is the operator's dedicated per-round forfeit penalty + // key, distinct from the operator identity key. The forfeit-tx penalty + // output is a BIP-86 key-spend to this key. + forfeitPubKey *btcec.PublicKey + // Runtime state tracking for assertions. currentState ClientState lastTransition *ClientStateTransition @@ -386,19 +391,19 @@ func newTestHarness(t *testing.T) *boardingTestHarness { vtxoStore := &MockVTXOStore{} wallet := &MockClientWallet{} - // Create a test forfeit script (a valid P2TR script). - testForfeitScript := []byte{0x51, 0x20} + // The forfeit penalty key is delivered per round (no longer a global + // operator term); derive a dedicated key distinct from the operator + // identity key. + _, forfeitPubKey := generateTestKeyPair(t) terms := &types.OperatorTerms{ PubKey: operatorPubKey, - SweepKey: operatorPubKey, - SweepDelay: 1008, // ~1 week in blocks. + VTXOExitDelay: 144, // 1 day in blocks. DustLimit: 546, MinBoardingAmount: 10000, MaxBoardingAmount: 100000000, // 1 BTC. FeeRate: 10, MinConfirmations: 3, - ForfeitScript: testForfeitScript, } // Use a mock start height for testing. @@ -440,6 +445,7 @@ func newTestHarness(t *testing.T) *boardingTestHarness { clientPubKey: clientPubKey, operatorPrivKey: operatorPrivKey, operatorPubKey: operatorPubKey, + forfeitPubKey: forfeitPubKey, currentState: &Idle{}, outboxMessages: make([]ClientOutMsg, 0), } @@ -831,6 +837,9 @@ func (h *boardingTestHarness) newCommitmentTxBuiltEvent(roundID RoundID, VTXOTreePaths: map[int]*tree.Tree{ 0: vtxtTree, }, + SweepKey: h.operatorPubKey, + SweepDelay: 1008, // ~1 week in blocks, must exceed exit delay. + ForfeitKey: h.forfeitPubKey, } } @@ -2058,8 +2067,9 @@ func (h *boardingTestHarness) newTestForfeitTx( // newForfeitCollectingState creates a ForfeitSignaturesCollectingState with // the given parameters for testing forfeit signature collection. func (h *boardingTestHarness) newForfeitCollectingState(roundID RoundID, - intents Intents, expectedForfeits map[wire.OutPoint]*ConnectorLeafInfo, - serverForfeitScript []byte) *ForfeitSignaturesCollectingState { + intents Intents, + expectedForfeits map[wire.OutPoint]*ConnectorLeafInfo, +) *ForfeitSignaturesCollectingState { h.t.Helper() @@ -2078,6 +2088,7 @@ func (h *boardingTestHarness) newForfeitCollectingState(roundID RoundID, 0: vtxtTree, }, Intents: intents, + ForfeitKey: h.forfeitPubKey, ClientTrees: make(map[SignerKey]*tree.Tree), BoardingInputIndices: boardingInputIndices, ExpectedForfeits: expectedForfeits, @@ -2087,6 +2098,18 @@ func (h *boardingTestHarness) newForfeitCollectingState(roundID RoundID, } } +// forfeitScript returns the BIP-86 key-spend penalty output script for the +// harness's per-round forfeit key, matching what the FSM derives from +// ForfeitKey when validating and building forfeit transactions. +func (h *boardingTestHarness) forfeitScript() []byte { + h.t.Helper() + + script, err := forfeitPenaltyScript(h.forfeitPubKey) + require.NoError(h.t, err) + + return script +} + // newTestCommitmentTxWithInputs creates a test commitment tx with the // specified number of inputs. Used for testing refresh-only rounds where there // are no boarding inputs. diff --git a/round/states.go b/round/states.go index d7012623a..4d720d5c0 100644 --- a/round/states.go +++ b/round/states.go @@ -3,6 +3,7 @@ package round import ( "fmt" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -275,6 +276,35 @@ type CommitmentTxReceivedState struct { // VTXOTreePaths maps commitment tx output indices to VTXO tree paths. VTXOTreePaths map[int]*tree.Tree + // TreeCosignKey is the operator's per-round VTXO-tree MuSig2 cosigner + // key delivered with the commitment tx. Used to validate the VTXO tree + // (and, via the extracted client trees, sign it) instead of the global + // operator key. Nil when the server predates the field; the FSM then + // falls back to the global operator key. + TreeCosignKey *btcec.PublicKey + + // ConnectorOperatorKey is the operator key this round used to build its + // connector tree. Used to reconstruct/validate the connector ancestry + // instead of the global operator key. Nil for older servers (fallback). + ConnectorOperatorKey *btcec.PublicKey + + // SweepKey is the operator sweep key for this round's VTXO-tree sweep + // leaf, delivered with the commitment tx. It replaces the global + // GetInfo sweep key for this round's tree reconstruction. + SweepKey *btcec.PublicKey + + // SweepDelay is this round's batch-wide absolute-timelock in blocks for + // the VTXO-tree sweep leaf, delivered with the commitment tx. It + // replaces the global GetInfo sweep delay and drives batch-expiry + // computation for VTXOs created in this round. + SweepDelay uint32 + + // ForfeitKey is the operator's dedicated forfeit penalty key for this + // round, delivered with the commitment tx. The forfeit-tx penalty + // output script is a BIP-86 key-spend to this key; it replaces the + // global GetInfo forfeit script for this round. + ForfeitKey *btcec.PublicKey + // Intents contains all the client's intents for this round. Intents Intents @@ -313,6 +343,18 @@ type CommitmentTxValidatedState struct { // VTXOTreePaths maps commitment tx output indices to VTXO tree paths. VTXOTreePaths map[int]*tree.Tree + // SweepDelay is this round's batch-wide absolute-timelock in blocks + // for the VTXO-tree sweep leaf, carried through the signing ceremony + // so batch expiry can be computed on confirmation. Delivered per round + // (not a global operator term). + SweepDelay uint32 + + // ForfeitKey is the operator's dedicated forfeit penalty key for this + // round, carried through the signing ceremony so the forfeit-tx + // penalty output (a BIP-86 key-spend to this key) can be built and + // validated. Delivered per round (not a global operator term). + ForfeitKey *btcec.PublicKey + // Intents contains all the client's intents for this round. Intents Intents @@ -360,6 +402,18 @@ type ForfeitSignaturesCollectingState struct { // VTXOTreePaths maps commitment tx output indices to VTXO tree paths. VTXOTreePaths map[int]*tree.Tree + // SweepDelay is this round's batch-wide absolute-timelock in blocks + // for the VTXO-tree sweep leaf, carried through the signing ceremony + // so batch expiry can be computed on confirmation. Delivered per round + // (not a global operator term). + SweepDelay uint32 + + // ForfeitKey is the operator's dedicated forfeit penalty key for this + // round, carried through the signing ceremony so the forfeit-tx + // penalty output (a BIP-86 key-spend to this key) can be built and + // validated. Delivered per round (not a global operator term). + ForfeitKey *btcec.PublicKey + // Intents contains all the client's intents for this round. Intents Intents @@ -402,6 +456,18 @@ type NoncesSentState struct { // VTXOTreePaths maps commitment tx output indices to VTXO tree paths. VTXOTreePaths map[int]*tree.Tree + // SweepDelay is this round's batch-wide absolute-timelock in blocks + // for the VTXO-tree sweep leaf, carried through the signing ceremony + // so batch expiry can be computed on confirmation. Delivered per round + // (not a global operator term). + SweepDelay uint32 + + // ForfeitKey is the operator's dedicated forfeit penalty key for this + // round, carried through the signing ceremony so the forfeit-tx + // penalty output (a BIP-86 key-spend to this key) can be built and + // validated. Delivered per round (not a global operator term). + ForfeitKey *btcec.PublicKey + // Intents contains all the client's intents for this round. Intents Intents @@ -445,6 +511,18 @@ type NoncesAggregatedState struct { // VTXOTreePaths maps commitment tx output indices to VTXO tree paths. VTXOTreePaths map[int]*tree.Tree + // SweepDelay is this round's batch-wide absolute-timelock in blocks + // for the VTXO-tree sweep leaf, carried through the signing ceremony + // so batch expiry can be computed on confirmation. Delivered per round + // (not a global operator term). + SweepDelay uint32 + + // ForfeitKey is the operator's dedicated forfeit penalty key for this + // round, carried through the signing ceremony so the forfeit-tx + // penalty output (a BIP-86 key-spend to this key) can be built and + // validated. Delivered per round (not a global operator term). + ForfeitKey *btcec.PublicKey + // Intents contains all the client's intents for this round. Intents Intents @@ -491,6 +569,18 @@ type PartialSigsSentState struct { // VTXOTreePaths maps commitment tx output indices to VTXO tree paths. VTXOTreePaths map[int]*tree.Tree + // SweepDelay is this round's batch-wide absolute-timelock in blocks + // for the VTXO-tree sweep leaf, carried through the signing ceremony + // so batch expiry can be computed on confirmation. Delivered per round + // (not a global operator term). + SweepDelay uint32 + + // ForfeitKey is the operator's dedicated forfeit penalty key for this + // round, carried through the signing ceremony so the forfeit-tx + // penalty output (a BIP-86 key-spend to this key) can be built and + // validated. Delivered per round (not a global operator term). + ForfeitKey *btcec.PublicKey + // Intents contains all the client's intents for this round. Intents Intents @@ -534,6 +624,18 @@ type InputSigSentState struct { // VTXOTreePaths maps commitment tx output indices to VTXO tree paths. VTXOTreePaths map[int]*tree.Tree + // SweepDelay is this round's batch-wide absolute-timelock in blocks + // for the VTXO-tree sweep leaf, carried through the signing ceremony + // so batch expiry can be computed on confirmation. Delivered per round + // (not a global operator term). + SweepDelay uint32 + + // ForfeitKey is the operator's dedicated forfeit penalty key for this + // round, carried through the signing ceremony so the forfeit-tx + // penalty output (a BIP-86 key-spend to this key) can be built and + // validated. Delivered per round (not a global operator term). + ForfeitKey *btcec.PublicKey + // Intents contains all the client's intents for this round. Intents Intents diff --git a/round/transitions.go b/round/transitions.go index 390a16142..10d5fbc9c 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -1370,13 +1370,20 @@ func (s *RoundJoinedState) ProcessEvent(ctx context.Context, event ClientEvent, return &ClientStateTransition{ NextState: &CommitmentTxReceivedState{ - RoundID: evt.RoundID, - CommitmentTx: evt.Tx, - TxID: txid, - VTXOTreePaths: evt.VTXOTreePaths, - Intents: s.Intents.Clone(), - ClientTrees: make(map[SignerKey]*tree.Tree), - Quote: s.Quote, + RoundID: evt.RoundID, + CommitmentTx: evt.Tx, + TxID: txid, + VTXOTreePaths: evt.VTXOTreePaths, + TreeCosignKey: evt.TreeCosignKey, + ConnectorOperatorKey: evt.ConnectorOperatorKey, + SweepKey: evt.SweepKey, + SweepDelay: evt.SweepDelay, + ForfeitKey: evt.ForfeitKey, + Intents: s.Intents.Clone(), + ClientTrees: make( + map[SignerKey]*tree.Tree, + ), + Quote: s.Quote, }, NewEvents: fn.Some(ClientEmittedEvent{ InternalEvent: []ClientEvent{evt}, @@ -1615,6 +1622,49 @@ func (s *CommitmentTxReceivedState) ProcessEvent(ctx context.Context, slog.Int("leave_intent_count", len(s.Intents.Leaves)), ) + // Resolve this round's operator signing keys, falling back to + // the global operator key when talking to a server that + // predates the per-round fields. treeCosignKey is the + // operator's MuSig2 cosigner for this round's VTXO tree + // (independent of the identity key); connectorOperatorKey is + // what the connector tree was built with. Using the + // round-delivered keys keeps a client on a previous + // operator-key epoch in agreement with the server. + treeCosignKey := s.TreeCosignKey + if treeCosignKey == nil { + treeCosignKey = env.OperatorTerms.PubKey + } + connectorOperatorKey := s.ConnectorOperatorKey + if connectorOperatorKey == nil { + connectorOperatorKey = env.OperatorTerms.PubKey + } + + // Validate this round's sweep delay against the VTXO exit + // delay. The sweep delay is now delivered per round (not a + // global operator term), so the security check that the + // operator has time to respond to unilateral exits before the + // batch expires moves here, where the round-specific value is + // known. + if err := ValidateDelayParameters( + s.SweepDelay, env.OperatorTerms.VTXOExitDelay, + ); err != nil { + + env.Log.WarnS( + ctx, + "Round sweep delay validation failed", + err, + slog.String("round_id", s.RoundID.String()), + ) + + return &ClientStateTransition{ + NextState: &ClientFailedState{ + Reason: "invalid round sweep delay", + Error: err, + Recoverable: false, + }, + }, nil + } + // Validate boarding inputs if we have any boarding intents. // Refresh-only rounds have no boarding inputs to validate. var boardingInputIndices map[wire.OutPoint]int @@ -1739,7 +1789,7 @@ func (s *CommitmentTxReceivedState) ProcessEvent(ctx context.Context, for _, vtxoTree := range s.VTXOTreePaths { clientTree, validateErr = vtxoTree.ValidatePath( vtxoReq.SigningKey.PubKey, expectedLeaf, - env.OperatorTerms.PubKey, + treeCosignKey, ) if validateErr == nil { // Found the VTXO in this tree. @@ -1828,7 +1878,7 @@ func (s *CommitmentTxReceivedState) ProcessEvent(ctx context.Context, // deterministic CPU work (the lib/tree materializer ignores its // context); there is no I/O to cancel, so no context to thread. if err := validateConnectorAncestry( - s.CommitmentTx.UnsignedTx, env.OperatorTerms.PubKey, + s.CommitmentTx.UnsignedTx, connectorOperatorKey, evt.ForfeitMappings, ); err != nil { // Error carried into failed state. @@ -1860,6 +1910,8 @@ func (s *CommitmentTxReceivedState) ProcessEvent(ctx context.Context, RoundID: s.RoundID, CommitmentTx: s.CommitmentTx, VTXOTreePaths: s.VTXOTreePaths, + SweepDelay: s.SweepDelay, + ForfeitKey: s.ForfeitKey, Intents: s.Intents.Clone(), ClientTrees: clientTrees, BoardingInputIndices: boardingInputIndices, @@ -1915,6 +1967,23 @@ func (s *CommitmentTxValidatedState) ProcessEvent(ctx context.Context, slog.String("round_id", s.RoundID.String()), slog.Int("leave_count", len(s.Intents.Leaves))) + // Derive this round's forfeit penalty output script + // from the per-round forfeit key (replacing the removed + // global GetInfo forfeit script). + forfeitScript, err := forfeitPenaltyScript(s.ForfeitKey) + if err != nil { + + // Error carried into failed state. + return &ClientStateTransition{ //nolint:nilerr + NextState: &ClientFailedState{ + Reason: "invalid round " + + "forfeit key", + Error: err, + Recoverable: false, + }, + }, nil + } + // Build forfeit request messages for each VTXO being // forfeited. forfeitReqs := forfeitRequestMap( @@ -1925,7 +1994,6 @@ func (s *CommitmentTxValidatedState) ProcessEvent(ctx context.Context, connOut := info.ConnectorOutpoint connScript := info.ConnectorPkScript connAmt := info.ConnectorAmount - forfeitScript := env.OperatorTerms.ForfeitScript roundIDStr := s.RoundID.String() req := forfeitReqs[vtxoOutpoint] @@ -1957,6 +2025,8 @@ func (s *CommitmentTxValidatedState) ProcessEvent(ctx context.Context, RoundID: s.RoundID, CommitmentTx: s.CommitmentTx, VTXOTreePaths: s.VTXOTreePaths, + SweepDelay: s.SweepDelay, + ForfeitKey: s.ForfeitKey, Intents: s.Intents.Clone(), ClientTrees: s.ClientTrees, ExpectedForfeits: s.ForfeitMappings, @@ -2044,6 +2114,8 @@ func (s *CommitmentTxValidatedState) ProcessEvent(ctx context.Context, RoundID: s.RoundID, CommitmentTx: s.CommitmentTx, VTXOTreePaths: s.VTXOTreePaths, + SweepDelay: s.SweepDelay, + ForfeitKey: s.ForfeitKey, Intents: s.Intents.Clone(), ClientTrees: s.ClientTrees, Musig2Sessions: musig2Sessions, @@ -2083,13 +2155,22 @@ func (s *ForfeitSignaturesCollectingState) ProcessEvent(ctx context.Context, forfeitReqs := forfeitRequestMap(s.Intents.Forfeits) req := forfeitReqs[evt.VTXOOutpoint] + // Derive this round's forfeit penalty output script from the + // per-round forfeit key (replacing the removed global GetInfo + // forfeit script). + forfeitScript, err := forfeitPenaltyScript(s.ForfeitKey) + if err != nil { + return nil, fmt.Errorf("forfeit penalty script for "+ + "VTXO %s: %w", evt.VTXOOutpoint, err) + } + // Validate the forfeit transaction structure using lib/tx. The // amount check ensures the zero-fee penalty output equals the // forfeited VTXO plus connector value, preventing value theft. params := tx.ForfeitTxParams{ VTXOOutpoint: evt.VTXOOutpoint, ConnectorOutpoint: connectorInfo.ConnectorOutpoint, - ServerForfeitScript: env.OperatorTerms.ForfeitScript, + ServerForfeitScript: forfeitScript, ExpectedAmount: btcutil.Amount( int64(connectorInfo.VTXOAmount) + connectorInfo.ConnectorAmount, @@ -2097,7 +2178,7 @@ func (s *ForfeitSignaturesCollectingState) ProcessEvent(ctx context.Context, ExpectedSequence: expectedForfeitSequence(req), ExpectedLockTime: expectedForfeitLockTime(req), } - err := tx.ValidateForfeitTx(evt.ForfeitTx, params) + err = tx.ValidateForfeitTx(evt.ForfeitTx, params) if err != nil { return nil, fmt.Errorf("invalid forfeit tx for VTXO "+ "%s: %w", evt.VTXOOutpoint, err) @@ -2187,6 +2268,8 @@ func (s *ForfeitSignaturesCollectingState) waitForMoreForfeitSignatures( RoundID: s.RoundID, CommitmentTx: s.CommitmentTx, VTXOTreePaths: s.VTXOTreePaths, + SweepDelay: s.SweepDelay, + ForfeitKey: s.ForfeitKey, Intents: s.Intents.Clone(), ClientTrees: s.ClientTrees, BoardingInputIndices: s.BoardingInputIndices, @@ -2332,6 +2415,8 @@ func (s *ForfeitSignaturesCollectingState) inputSigSentState( RoundID: s.RoundID, CommitmentTx: s.CommitmentTx, VTXOTreePaths: s.VTXOTreePaths, + SweepDelay: s.SweepDelay, + ForfeitKey: s.ForfeitKey, Intents: s.Intents.Clone(), ClientTrees: s.ClientTrees, InputSigs: boardingInputSigs, @@ -2385,6 +2470,8 @@ func (s *NoncesSentState) ProcessEvent(ctx context.Context, event ClientEvent, RoundID: s.RoundID, CommitmentTx: s.CommitmentTx, VTXOTreePaths: s.VTXOTreePaths, + SweepDelay: s.SweepDelay, + ForfeitKey: s.ForfeitKey, Intents: s.Intents.Clone(), ClientTrees: s.ClientTrees, Musig2Sessions: s.Musig2Sessions, @@ -2468,6 +2555,8 @@ func (s *NoncesAggregatedState) ProcessEvent(ctx context.Context, RoundID: s.RoundID, CommitmentTx: s.CommitmentTx, VTXOTreePaths: s.VTXOTreePaths, + SweepDelay: s.SweepDelay, + ForfeitKey: s.ForfeitKey, Intents: s.Intents.Clone(), ClientTrees: s.ClientTrees, Musig2Sessions: s.Musig2Sessions, @@ -2667,6 +2756,8 @@ func (s *PartialSigsSentState) ProcessEvent(ctx context.Context, RoundID: s.RoundID, CommitmentTx: s.CommitmentTx, VTXOTreePaths: s.VTXOTreePaths, + SweepDelay: s.SweepDelay, + ForfeitKey: s.ForfeitKey, Intents: s.Intents.Clone(), ClientTrees: s.ClientTrees, InputSigs: boardingInputSigs, @@ -2721,8 +2812,22 @@ func (s *PartialSigsSentState) transitionToForfeitCollection( ctx context.Context, env *ClientEnvironment) (*ClientStateTransition, error) { - // Build forfeit request messages for each VTXO being refreshed. The - // forfeit script is a static operator property from OperatorTerms. + // Derive this round's forfeit penalty output script from the per-round + // forfeit key (replacing the removed global GetInfo forfeit script). + forfeitScript, err := forfeitPenaltyScript(s.ForfeitKey) + if err != nil { + + // Error carried into failed state. + return &ClientStateTransition{ //nolint:nilerr + NextState: &ClientFailedState{ + Reason: "invalid round forfeit key", + Error: err, + Recoverable: false, + }, + }, nil + } + + // Build forfeit request messages for each VTXO being refreshed. forfeitReqs := forfeitRequestMap(s.Intents.Forfeits) var outbox []ClientOutMsg for vtxoOutpoint, info := range s.ForfeitMappings { @@ -2733,7 +2838,7 @@ func (s *PartialSigsSentState) transitionToForfeitCollection( ConnectorOutpoint: info.ConnectorOutpoint, ConnectorPkScript: info.ConnectorPkScript, ConnectorAmount: info.ConnectorAmount, - ServerForfeitPkScript: env.OperatorTerms.ForfeitScript, + ServerForfeitPkScript: forfeitScript, ForfeitSpend: req.ForfeitSpend, } outbox = append(outbox, msg) @@ -2761,6 +2866,8 @@ func (s *PartialSigsSentState) transitionToForfeitCollection( RoundID: s.RoundID, CommitmentTx: s.CommitmentTx, VTXOTreePaths: s.VTXOTreePaths, + SweepDelay: s.SweepDelay, + ForfeitKey: s.ForfeitKey, Intents: s.Intents.Clone(), ClientTrees: s.ClientTrees, ExpectedForfeits: s.ForfeitMappings, @@ -3094,6 +3201,22 @@ func expectedForfeitLockTime(req types.ForfeitRequest) uint32 { return req.ForfeitSpend.RequiredLockTime } +// forfeitPenaltyScript derives the forfeit-tx penalty output script for a +// round from the operator's per-round forfeit key. The script is a BIP-86 +// key-spend (no script tree) to the forfeit key, so the server can claim the +// penalty output with a single Schnorr signature. The forfeit key is +// delivered per round in ClientBatchInfo and replaces the removed global +// GetInfo forfeit script. +func forfeitPenaltyScript(forfeitKey *btcec.PublicKey) ([]byte, error) { + if forfeitKey == nil { + return nil, fmt.Errorf("round forfeit key not set") + } + + taprootKey := txscript.ComputeTaprootKeyNoScript(forfeitKey) + + return txscript.PayToTaprootScript(taprootKey) +} + // ensureVTXOSigningKeys fills missing VTXO signing keys by deriving fresh // keys from the wallet. Existing signing keys are preserved. func ensureVTXOSigningKeys(ctx context.Context, wallet ClientWallet, @@ -3365,8 +3488,9 @@ func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, slog.Int("vtxo_count", len(vtxos)), ) - // Compute batch expiry as absolute block height. - sweepDelay := int32(env.OperatorTerms.SweepDelay) + // Compute batch expiry as absolute block height using this + // round's sweep delay (delivered per round, not a global term). + sweepDelay := int32(s.SweepDelay) batchExpiry := evt.BlockHeight + sweepDelay // Fill in round metadata so VTXOs are complete from the diff --git a/round/transitions_test.go b/round/transitions_test.go index de60b4f4c..fb4acce5a 100644 --- a/round/transitions_test.go +++ b/round/transitions_test.go @@ -1275,6 +1275,7 @@ func TestCommitmentTxReceivedState(t *testing.T) { VTXOs: vtxos, }, ClientTrees: make(map[SignerKey]*tree.Tree), + SweepDelay: 1008, } h.withState(state) @@ -1333,6 +1334,7 @@ func TestCommitmentTxReceivedState(t *testing.T) { }, }, ClientTrees: make(map[SignerKey]*tree.Tree), + SweepDelay: 1008, } h.withState(state) @@ -1401,6 +1403,7 @@ func TestCommitmentTxReceivedState(t *testing.T) { VTXOs: vtxos, }, ClientTrees: make(map[SignerKey]*tree.Tree), + SweepDelay: 1008, Quote: &ClientQuote{ OperatorFeeSat: 1500, VTXOQuotes: []VTXOQuoteEntry{{ @@ -1478,6 +1481,7 @@ func TestCommitmentTxReceivedState(t *testing.T) { VTXOs: vtxos, }, ClientTrees: make(map[SignerKey]*tree.Tree), + SweepDelay: 1008, Quote: &ClientQuote{ OperatorFeeSat: 1500, VTXOQuotes: []VTXOQuoteEntry{{ @@ -1569,6 +1573,7 @@ func TestCommitmentTxReceivedState(t *testing.T) { Leaves: leaves, }, ClientTrees: make(map[SignerKey]*tree.Tree), + SweepDelay: 1008, Quote: &ClientQuote{ OperatorFeeSat: 1500, VTXOQuotes: []VTXOQuoteEntry{{ @@ -1650,6 +1655,7 @@ func TestCommitmentTxValidatedState(t *testing.T) { RoundID: roundID, CommitmentTx: commitmentTx, VTXOTreePaths: map[int]*tree.Tree{}, + ForfeitKey: h.forfeitPubKey, Intents: Intents{ Boarding: []BoardingIntent{ intent, @@ -1946,6 +1952,7 @@ func TestPartialSigsSentState(t *testing.T) { RoundID: roundID, CommitmentTx: commitmentTx, VTXOTreePaths: map[int]*tree.Tree{}, + ForfeitKey: h.forfeitPubKey, Intents: Intents{ Boarding: []BoardingIntent{ intent, @@ -2844,7 +2851,7 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { intent := h.newTestBoardingIntent() vtxoOutpoint := h.newTestOutpoint() connectorOutpoint := h.newTestOutpoint() - serverForfeitScript := []byte{0x51, 0x20} + serverForfeitScript := h.forfeitScript() // Build a valid forfeit tx structure for validation. forfeitTx := h.newTestForfeitTx( @@ -2864,7 +2871,6 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { VTXOAmount: 50000, }, }, - serverForfeitScript, ) h.withState(state) @@ -2907,7 +2913,7 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { vtxoOutpoint := h.newTestOutpoint() connectorOutpoint := h.newTestOutpoint() - serverForfeitScript := []byte{0x51, 0x20} + serverForfeitScript := h.forfeitScript() forfeitTx := h.newTestForfeitTx( vtxoOutpoint, connectorOutpoint, @@ -2930,7 +2936,6 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { map[wire.OutPoint]*ConnectorLeafInfo{ vtxoOutpoint: connectorInfo, }, - serverForfeitScript, ) h.withState(state) @@ -2979,7 +2984,7 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { vtxoOutpoint2 := h.newTestOutpoint() connectorOutpoint1 := h.newTestOutpoint() connectorOutpoint2 := h.newTestOutpoint() - serverForfeitScript := []byte{0x51, 0x20} + serverForfeitScript := h.forfeitScript() roundID := testRoundIDTr("round-forfeit-002") state := h.newForfeitCollectingState( @@ -3001,7 +3006,6 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { VTXOAmount: 50000, }, }, - serverForfeitScript, ) h.withState(state) @@ -3056,7 +3060,7 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { intent := h.newTestBoardingIntent() vtxoOutpoint := h.newTestOutpoint() connectorOutpoint := h.newTestOutpoint() - serverForfeitScript := []byte{0x51, 0x20} + serverForfeitScript := h.forfeitScript() // We need 2 expected forfeits to stay in state after first. vtxoOutpoint2 := h.newTestOutpoint() @@ -3082,7 +3086,6 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { VTXOAmount: 50000, }, }, - serverForfeitScript, ) h.withState(state) @@ -3126,7 +3129,6 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { intent := h.newTestBoardingIntent() vtxoOutpoint := h.newTestOutpoint() connectorOutpoint := h.newTestOutpoint() - serverForfeitScript := []byte{0x51, 0x20} roundID := testRoundIDTr("round-forfeit-004") state := h.newForfeitCollectingState( @@ -3141,7 +3143,6 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { VTXOAmount: 50000, }, }, - serverForfeitScript, ) h.withState(state) @@ -3181,7 +3182,6 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { VTXOAmount: 50000, }, }, - []byte{0x51, 0x20}, ) h.withState(state) @@ -3222,7 +3222,6 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { VTXOAmount: 50000, }, }, - []byte{0x51, 0x20}, ) h.withState(state) @@ -3249,7 +3248,7 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { intent := h.newTestBoardingIntent() vtxoOutpoint := h.newTestOutpoint() connectorOutpoint := h.newTestOutpoint() - serverForfeitScript := []byte{0x51, 0x20} + serverForfeitScript := h.forfeitScript() // Create forfeit tx with 40000 sats (mismatch). forfeitTx := wire.NewMsgTx(2) @@ -3284,7 +3283,6 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { VTXOAmount: 50000, }, }, - serverForfeitScript, ) h.withState(state) @@ -3428,7 +3426,7 @@ func TestForfeitCollectionStateImmutability(t *testing.T) { vtxoOutpoint2 := h.newTestOutpoint() connectorOutpoint1 := h.newTestOutpoint() connectorOutpoint2 := h.newTestOutpoint() - serverForfeitScript := []byte{0x51, 0x20} + serverForfeitScript := h.forfeitScript() roundID := testRoundIDTr("round-immut-001") state := h.newForfeitCollectingState( @@ -3450,7 +3448,6 @@ func TestForfeitCollectionStateImmutability(t *testing.T) { VTXOAmount: 50000, }, }, - serverForfeitScript, ) // Save original state's CollectedForfeits map reference. @@ -3518,6 +3515,7 @@ func TestRefreshOnlyRoundValidation(t *testing.T) { }, Intents: emptyIntents, ClientTrees: make(map[SignerKey]*tree.Tree), + SweepDelay: 1008, } h.withState(state) diff --git a/rpc/roundpb/round.pb.go b/rpc/roundpb/round.pb.go index cf921d6dd..40422fd19 100644 --- a/rpc/roundpb/round.pb.go +++ b/rpc/roundpb/round.pb.go @@ -635,8 +635,40 @@ type ClientBatchInfo struct { // wire.OutPoint.String() (byte-reversed hex hash, colon, decimal // index). ConnectorLeafMap map[string]*ConnectorLeafInfo `protobuf:"bytes,4,rep,name=connector_leaf_map,json=connectorLeafMap,proto3" json:"connector_leaf_map,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // tree_cosign_key is this round's VTXO-tree MuSig2 cosigner public key + // (33-byte compressed). It is derived fresh per round and is independent + // of the operator's persistent identity key, so rotating the operator + // key never changes the tree-signing key clients must agree on. The + // client uses this — not the global operator terms — as the server + // cosigner when validating and signing this round's VTXO output tree. + TreeCosignKey []byte `protobuf:"bytes,5,opt,name=tree_cosign_key,json=treeCosignKey,proto3" json:"tree_cosign_key,omitempty"` + // connector_operator_key is the operator key (33-byte compressed) this + // round used to build its connector tree. The client reconstructs the + // connector tree from this key — not the global operator terms — so a + // client still on a previous operator-key epoch derives the same + // connector outpoints the server did. + ConnectorOperatorKey []byte `protobuf:"bytes,6,opt,name=connector_operator_key,json=connectorOperatorKey,proto3" json:"connector_operator_key,omitempty"` + // sweep_key is the operator sweep key (33-byte compressed) for this + // round's VTXO-tree sweep leaf. The client rebuilds the VTXO tree's + // sweep branch from this key — not the global operator terms — so an + // operator key rotation never changes the sweep key clients must agree + // on for the round they joined. + SweepKey []byte `protobuf:"bytes,7,opt,name=sweep_key,json=sweepKey,proto3" json:"sweep_key,omitempty"` + // sweep_delay is this round's batch-wide absolute-timelock in blocks for + // the VTXO-tree sweep leaf. The client uses it — not the global operator + // terms — to compute the batch expiry and reconstruct the sweep branch, + // keeping a client on a previous operator-key epoch in agreement with the + // server. + SweepDelay uint32 `protobuf:"varint,8,opt,name=sweep_delay,json=sweepDelay,proto3" json:"sweep_delay,omitempty"` + // forfeit_key is the operator's dedicated forfeit penalty key (33-byte + // compressed) for this round. The client derives the forfeit-tx penalty + // output script as a BIP-86 key-spend to this key — not the global + // operator terms — so an operator key rotation never changes the penalty + // script clients must agree on for the round they joined. It is a key + // family distinct from the operator identity/connector key. + ForfeitKey []byte `protobuf:"bytes,9,opt,name=forfeit_key,json=forfeitKey,proto3" json:"forfeit_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ClientBatchInfo) Reset() { @@ -697,6 +729,41 @@ func (x *ClientBatchInfo) GetConnectorLeafMap() map[string]*ConnectorLeafInfo { return nil } +func (x *ClientBatchInfo) GetTreeCosignKey() []byte { + if x != nil { + return x.TreeCosignKey + } + return nil +} + +func (x *ClientBatchInfo) GetConnectorOperatorKey() []byte { + if x != nil { + return x.ConnectorOperatorKey + } + return nil +} + +func (x *ClientBatchInfo) GetSweepKey() []byte { + if x != nil { + return x.SweepKey + } + return nil +} + +func (x *ClientBatchInfo) GetSweepDelay() uint32 { + if x != nil { + return x.SweepDelay + } + return 0 +} + +func (x *ClientBatchInfo) GetForfeitKey() []byte { + if x != nil { + return x.ForfeitKey + } + return nil +} + // ClientAwaitingInputSigsResp notifies a client that the server is ready // to receive their boarding input signatures. type ClientAwaitingInputSigsResp struct { @@ -2422,13 +2489,20 @@ const file_round_proto_rawDesc = "" + "\x11ClientSuccessResp\x12\x19\n" + "\bround_id\x18\x01 \x01(\fR\aroundId\x12R\n" + "\x1baccepted_boarding_outpoints\x18\x02 \x03(\v2\x12.round.v1.OutpointR\x19acceptedBoardingOutpoints\x12J\n" + - "\x17accepted_vtxo_outpoints\x18\x03 \x03(\v2\x12.round.v1.OutpointR\x15acceptedVtxoOutpoints\"\xb8\x03\n" + + "\x17accepted_vtxo_outpoints\x18\x03 \x03(\v2\x12.round.v1.OutpointR\x15acceptedVtxoOutpoints\"\xf5\x04\n" + "\x0fClientBatchInfo\x12\x19\n" + "\bround_id\x18\x01 \x01(\fR\aroundId\x12\x1d\n" + "\n" + "batch_psbt\x18\x02 \x01(\fR\tbatchPsbt\x12T\n" + "\x0fvtxo_tree_paths\x18\x03 \x03(\v2,.round.v1.ClientBatchInfo.VtxoTreePathsEntryR\rvtxoTreePaths\x12]\n" + - "\x12connector_leaf_map\x18\x04 \x03(\v2/.round.v1.ClientBatchInfo.ConnectorLeafMapEntryR\x10connectorLeafMap\x1aT\n" + + "\x12connector_leaf_map\x18\x04 \x03(\v2/.round.v1.ClientBatchInfo.ConnectorLeafMapEntryR\x10connectorLeafMap\x12&\n" + + "\x0ftree_cosign_key\x18\x05 \x01(\fR\rtreeCosignKey\x124\n" + + "\x16connector_operator_key\x18\x06 \x01(\fR\x14connectorOperatorKey\x12\x1b\n" + + "\tsweep_key\x18\a \x01(\fR\bsweepKey\x12\x1f\n" + + "\vsweep_delay\x18\b \x01(\rR\n" + + "sweepDelay\x12\x1f\n" + + "\vforfeit_key\x18\t \x01(\fR\n" + + "forfeitKey\x1aT\n" + "\x12VtxoTreePathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\x05R\x03key\x12(\n" + "\x05value\x18\x02 \x01(\v2\x12.round.v1.VTXOTreeR\x05value:\x028\x01\x1a`\n" + diff --git a/rpc/roundpb/round.proto b/rpc/roundpb/round.proto index dcaee4420..8a6ff219c 100644 --- a/rpc/roundpb/round.proto +++ b/rpc/roundpb/round.proto @@ -168,6 +168,43 @@ message ClientBatchInfo { // wire.OutPoint.String() (byte-reversed hex hash, colon, decimal // index). map connector_leaf_map = 4; + + // tree_cosign_key is this round's VTXO-tree MuSig2 cosigner public key + // (33-byte compressed). It is derived fresh per round and is independent + // of the operator's persistent identity key, so rotating the operator + // key never changes the tree-signing key clients must agree on. The + // client uses this — not the global operator terms — as the server + // cosigner when validating and signing this round's VTXO output tree. + bytes tree_cosign_key = 5; + + // connector_operator_key is the operator key (33-byte compressed) this + // round used to build its connector tree. The client reconstructs the + // connector tree from this key — not the global operator terms — so a + // client still on a previous operator-key epoch derives the same + // connector outpoints the server did. + bytes connector_operator_key = 6; + + // sweep_key is the operator sweep key (33-byte compressed) for this + // round's VTXO-tree sweep leaf. The client rebuilds the VTXO tree's + // sweep branch from this key — not the global operator terms — so an + // operator key rotation never changes the sweep key clients must agree + // on for the round they joined. + bytes sweep_key = 7; + + // sweep_delay is this round's batch-wide absolute-timelock in blocks for + // the VTXO-tree sweep leaf. The client uses it — not the global operator + // terms — to compute the batch expiry and reconstruct the sweep branch, + // keeping a client on a previous operator-key epoch in agreement with the + // server. + uint32 sweep_delay = 8; + + // forfeit_key is the operator's dedicated forfeit penalty key (33-byte + // compressed) for this round. The client derives the forfeit-tx penalty + // output script as a BIP-86 key-spend to this key — not the global + // operator terms — so an operator key rotation never changes the penalty + // script clients must agree on for the round they joined. It is a key + // family distinct from the operator identity/connector key. + bytes forfeit_key = 9; } // ClientAwaitingInputSigsResp notifies a client that the server is ready diff --git a/sdk/ark/client.go b/sdk/ark/client.go index 9e42b3a5f..02af3a727 100644 --- a/sdk/ark/client.go +++ b/sdk/ark/client.go @@ -131,17 +131,6 @@ type ServerInfo struct { // VTXOExitDelay is the minimum CSV delay required for VTXO outputs. VTXOExitDelay uint32 - // ForfeitScript is the raw serialized scriptPubKey required in - // forfeit transactions. - ForfeitScript []byte - - // SweepKey is the compressed SEC-encoded operator public key used in - // VTXO sweep paths when present. - SweepKey []byte - - // SweepDelay is the batch-wide absolute timelock in blocks. - SweepDelay uint32 - // DustLimit is the minimum output value accepted by the operator. DustLimit uint64 @@ -416,13 +405,6 @@ func (c *Client) GetInfo(ctx context.Context) (*Info, error) { ), BoardingExitDelay: resp.ServerInfo.BoardingExitDelay, VTXOExitDelay: resp.ServerInfo.VtxoExitDelay, - ForfeitScript: bytes.Clone( - resp.ServerInfo.ForfeitScript, - ), - SweepKey: bytes.Clone( - resp.ServerInfo.SweepKey, - ), - SweepDelay: resp.ServerInfo.SweepDelay, DustLimit: resp.ServerInfo.DustLimit, MinBoardingAmount: resp.ServerInfo.MinBoardingAmount, MaxBoardingAmount: resp.ServerInfo.MaxBoardingAmount, diff --git a/sdk/ark/client_test.go b/sdk/ark/client_test.go index b06c36f69..04a318fab 100644 --- a/sdk/ark/client_test.go +++ b/sdk/ark/client_test.go @@ -66,10 +66,6 @@ var ( // testOperatorPubKeyHex is the deterministic compressed pubkey used for // the fake operator terms. testOperatorPubKeyHex = compressedPubKeyHex(2) - - // testSweepPubKeyHex is the deterministic compressed pubkey used for - // the fake sweep key. - testSweepPubKeyHex = compressedPubKeyHex(3) ) // newFakeDaemonService creates a fake daemon service with deterministic @@ -91,13 +87,6 @@ func newFakeDaemonService() *fakeDaemonService { ), BoardingExitDelay: 144, VtxoExitDelay: 288, - ForfeitScript: []byte{ - 0x51, - }, - SweepKey: mustDecodeHex( - testSweepPubKeyHex, - ), - SweepDelay: 432, DustLimit: 546, MinBoardingAmount: 10_000, MaxBoardingAmount: 20_000, diff --git a/systest/send_vtxo_test.go b/systest/send_vtxo_test.go index 3a3678a88..997286d09 100644 --- a/systest/send_vtxo_test.go +++ b/systest/send_vtxo_test.go @@ -445,17 +445,11 @@ func newDirectedSendFixture(t *testing.T, operatorInfo := &arkrpc.GetInfoResponse{ Pubkey: operatorPriv.PubKey().SerializeCompressed(), - SweepKey: operatorPriv.PubKey().SerializeCompressed(), BoardingExitDelay: 144, VtxoExitDelay: 144, - ForfeitScript: []byte{ - 0x51, - 0x20, - }, - SweepDelay: 1008, - DustLimit: testDustLimitSat, - MinOperatorFee: testOperatorFeeSat, - MinConfirmations: 1, + DustLimit: testDustLimitSat, + MinOperatorFee: testOperatorFeeSat, + MinConfirmations: 1, } operatorMailbox := serverconn.PubKeyMailboxID(