staticaddr: checkChange method for sweep signing

This commit is contained in:
Slyghtning 2025-09-22 10:06:42 +02:00
parent 221372b5ef
commit a877cfdc4c
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
3 changed files with 443 additions and 23 deletions

View file

@ -79,6 +79,11 @@ type StaticAddressLoopInStore interface {
// GetLoopInByHash returns the loop-in swap with the given hash.
GetLoopInByHash(ctx context.Context, swapHash lntypes.Hash) (
*StaticAddressLoopIn, error)
// SwapHashesForDepositIDs returns a map of swap hashes to deposit IDs
// for the given deposit IDs.
SwapHashesForDepositIDs(ctx context.Context,
depositIDs []deposit.ID) (map[lntypes.Hash][]deposit.ID, error)
}
type QuoteGetter interface {

View file

@ -19,6 +19,7 @@ import (
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/input"
@ -324,29 +325,9 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context,
// If the user selected an amount that is less than the total deposit
// amount we'll check that the server sends us the correct change amount
// back to our static address.
totalDepositAmount := loopIn.TotalDepositAmount()
changeAmt := totalDepositAmount - loopIn.SelectedAmount
if changeAmt > 0 && changeAmt < totalDepositAmount {
var foundChange bool
changePkScript := loopIn.AddressParams.PkScript
for _, out := range sweepTx.TxOut {
if out.Value == int64(changeAmt) &&
bytes.Equal(out.PkScript, changePkScript) {
foundChange = true
break
}
}
if !foundChange {
return fmt.Errorf("expected change output to our "+
"static address, total_deposit_amount=%v, "+
"selected_amount=%v, "+
"expected_change_amount=%v ",
totalDepositAmount, loopIn.SelectedAmount,
changeAmt)
}
err = m.checkChange(ctx, sweepTx, loopIn.AddressParams)
if err != nil {
return err
}
// Check if all the deposits requested are part of the loop-in and
@ -465,6 +446,73 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context,
return err
}
// checkChange ensures that the server sends us the correct change amount
// back to our static address. An edge case arises if a batch contains two
// swaps with identical change outputs. The client needs to ensure that any
// swap referenced by the inputs has a respective change output in the batch.
func (m *Manager) checkChange(ctx context.Context,
sweepTx *wire.MsgTx, changeAddr *address.Parameters) error {
prevOuts := make([]string, len(sweepTx.TxIn))
for i, in := range sweepTx.TxIn {
prevOuts[i] = in.PreviousOutPoint.String()
}
deposits, err := m.cfg.DepositManager.DepositsForOutpoints(
ctx, prevOuts,
)
if err != nil {
return err
}
depositIDs := make([]deposit.ID, len(deposits))
for i, d := range deposits {
depositIDs[i] = d.ID
}
swapHashes, err := m.cfg.Store.SwapHashesForDepositIDs(ctx, depositIDs)
if err != nil {
return err
}
var expectedChange btcutil.Amount
for swapHash := range swapHashes {
loopIn, err := m.cfg.Store.GetLoopInByHash(ctx, swapHash)
if err != nil {
return err
}
totalDepositAmount := loopIn.TotalDepositAmount()
changeAmt := totalDepositAmount - loopIn.SelectedAmount
if changeAmt > 0 && changeAmt < totalDepositAmount {
log.Debugf("expected change output to our "+
"static address, total_deposit_amount=%v, "+
"selected_amount=%v, "+
"expected_change_amount=%v ",
totalDepositAmount, loopIn.SelectedAmount,
changeAmt)
expectedChange += changeAmt
}
}
if expectedChange == 0 {
return nil
}
for _, out := range sweepTx.TxOut {
if out.Value == int64(expectedChange) &&
bytes.Equal(out.PkScript, changeAddr.PkScript) {
// We found the expected change output.
return nil
}
}
return fmt.Errorf("couldn't find expected change of %v "+
"satoshis sent to our static address", expectedChange)
}
// recover stars a loop-in state machine for each non-final loop-in to pick up
// work where it was left off before the restart.
func (m *Manager) recoverLoopIns(ctx context.Context) error {

View file

@ -1,11 +1,16 @@
package loopin
import (
"context"
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/stretchr/testify/require"
)
@ -133,3 +138,365 @@ func TestSelectDeposits(t *testing.T) {
})
}
}
// mockDepositManager implements DepositManager for tests.
type mockDepositManager struct {
byOutpoint map[string]*deposit.Deposit
}
func (m *mockDepositManager) GetAllDeposits(_ context.Context) (
[]*deposit.Deposit, error) {
return nil, nil
}
func (m *mockDepositManager) AllStringOutpointsActiveDeposits(_ []string,
_ fsm.StateType) ([]*deposit.Deposit, bool) {
return nil, false
}
func (m *mockDepositManager) TransitionDeposits(_ context.Context,
_ []*deposit.Deposit, _ fsm.EventType, _ fsm.StateType) error {
return nil
}
func (m *mockDepositManager) DepositsForOutpoints(_ context.Context,
outpoints []string) ([]*deposit.Deposit, error) {
res := make([]*deposit.Deposit, 0, len(outpoints))
for _, op := range outpoints {
if d, ok := m.byOutpoint[op]; ok {
res = append(res, d)
}
}
return res, nil
}
func (m *mockDepositManager) GetActiveDepositsInState(_ fsm.StateType) (
[]*deposit.Deposit, error) {
return nil, nil
}
// mockStore implements StaticAddressLoopInStore for tests.
type mockStore struct {
loopIns map[lntypes.Hash]*StaticAddressLoopIn
mapIDs map[lntypes.Hash][]deposit.ID
}
func (s *mockStore) CreateLoopIn(_ context.Context,
_ *StaticAddressLoopIn) error {
return nil
}
func (s *mockStore) UpdateLoopIn(_ context.Context,
_ *StaticAddressLoopIn) error {
return nil
}
func (s *mockStore) GetStaticAddressLoopInSwapsByStates(_ context.Context,
_ []fsm.StateType) ([]*StaticAddressLoopIn, error) {
return nil, nil
}
func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) {
return false, nil
}
func (s *mockStore) GetLoopInByHash(_ context.Context,
swapHash lntypes.Hash) (*StaticAddressLoopIn, error) {
li, ok := s.loopIns[swapHash]
if !ok {
return nil, nil
}
return li, nil
}
func (s *mockStore) SwapHashesForDepositIDs(_ context.Context,
depositIDs []deposit.ID) (map[lntypes.Hash][]deposit.ID, error) {
// Filter the prepared mapping to only include hashes that reference
// any of the provided deposit IDs.
idSet := make(map[deposit.ID]struct{}, len(depositIDs))
for _, id := range depositIDs {
idSet[id] = struct{}{}
}
res := make(map[lntypes.Hash][]deposit.ID)
for h, ids := range s.mapIDs {
for _, id := range ids {
if _, ok := idSet[id]; ok {
res[h] = ids
break
}
}
}
return res, nil
}
// helper to create a deposit with specific outpoint and value.
func makeDeposit(h byte, index uint32, value btcutil.Amount) *deposit.Deposit {
d := &deposit.Deposit{Value: value}
d.Hash = chainhash.Hash{h}
d.Index = index
var id deposit.ID
id[0] = h
d.ID = id
return d
}
// helper to outpoint string as used by txin.PreviousOutPoint.String().
func outpointString(d *deposit.Deposit) string {
return wire.OutPoint{Hash: d.Hash, Index: d.Index}.String()
}
// build a sweep tx with given inputs and outputs.
func makeSweepTx(inputs []wire.OutPoint, outputs []*wire.TxOut) *wire.MsgTx {
tx := wire.NewMsgTx(2)
for _, in := range inputs {
tx.AddTxIn(&wire.TxIn{PreviousOutPoint: in})
}
for _, out := range outputs {
tx.AddTxOut(out)
}
return tx
}
// TestCheckChange exercises all relevant scenarios for checkChange.
func TestCheckChange(t *testing.T) {
ctx := context.Background()
// Prepare a common change address and an alternate address.
changeAddr := &address.Parameters{PkScript: []byte{0xaa, 0xbb}}
otherAddr := &address.Parameters{PkScript: []byte{0xcc, 0xdd}}
serverAddr := &address.Parameters{PkScript: []byte{0xee, 0xff}}
// Prepare swaps (loop-ins) with varying deposit totals and selections.
// Helper to make a swap with deposits and selected amount.
makeSwap := func(h byte, deposits []*deposit.Deposit,
selected btcutil.Amount) (lntypes.Hash, *StaticAddressLoopIn) {
var hash lntypes.Hash
hash[0] = h
li := &StaticAddressLoopIn{
Deposits: deposits,
SelectedAmount: selected,
AddressParams: changeAddr,
}
return hash, li
}
// Deposits belonging to different swaps.
s1d1 := makeDeposit(1, 0, 1000)
s1d2 := makeDeposit(1, 1, 2000)
s2d1 := makeDeposit(2, 0, 1500)
s3d1 := makeDeposit(3, 0, 800)
s4d1 := makeDeposit(4, 0, 900)
// Swaps:
// A: total 3000, selected 3000 => no change.
hA, liA := makeSwap(10, []*deposit.Deposit{s1d1, s1d2}, 3000)
// B: total 1500, selected 1000 => change 500.
hB, liB := makeSwap(11, []*deposit.Deposit{s2d1}, 1000)
// C: total 800, selected 400 => change 400.
hC, liC := makeSwap(12, []*deposit.Deposit{s3d1}, 400)
// D: total 900, selected 500 => change 400.
hD, liD := makeSwap(13, []*deposit.Deposit{s4d1}, 500)
// Mapping deposits -> swaps (by deposit IDs).
mapIDs := map[lntypes.Hash][]deposit.ID{
hA: {s1d1.ID, s1d2.ID},
hB: {s2d1.ID},
hC: {s3d1.ID},
hD: {s4d1.ID},
}
loopIns := map[lntypes.Hash]*StaticAddressLoopIn{
hA: liA,
hB: liB,
hC: liC,
hD: liD,
}
// Common manager with mocked dependencies; will change inputs per test.
mgr := &Manager{
cfg: &Config{
DepositManager: &mockDepositManager{
byOutpoint: map[string]*deposit.Deposit{},
},
Store: &mockStore{
loopIns: loopIns,
mapIDs: mapIDs,
},
},
}
type testCase struct {
name string
inDeps []*deposit.Deposit // deposits referenced by tx inputs
outputs []*wire.TxOut // outputs in sweep tx
addr *address.Parameters
expectErr bool
expectedErrMsg string
}
cases := []testCase{
{
name: "no change expected (selected == total)",
inDeps: []*deposit.Deposit{s1d1, s1d2},
// No change output required.
outputs: []*wire.TxOut{
{
Value: 1337,
PkScript: serverAddr.PkScript,
},
},
addr: changeAddr,
},
{
name: "single swap change present",
inDeps: []*deposit.Deposit{s2d1}, // B -> change 500
outputs: []*wire.TxOut{
{
Value: 1337,
PkScript: serverAddr.PkScript,
},
{
Value: 500,
PkScript: changeAddr.PkScript,
},
},
addr: changeAddr,
},
{
name: "multiple swaps different change amounts",
inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400)=900
outputs: []*wire.TxOut{
{
Value: 1337,
PkScript: serverAddr.PkScript,
},
{
Value: 900,
PkScript: changeAddr.PkScript,
},
},
addr: changeAddr,
},
{
name: "two swaps with identical change values sum correctly",
inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400)=800
outputs: []*wire.TxOut{
{
Value: 1337,
PkScript: serverAddr.PkScript,
},
{
Value: 800,
PkScript: changeAddr.PkScript,
},
},
addr: changeAddr,
},
{
name: "missing change output results in error",
inDeps: []*deposit.Deposit{s2d1}, // expect 500
outputs: []*wire.TxOut{},
addr: changeAddr,
expectErr: true,
expectedErrMsg: "couldn't find expected change",
},
{
name: "wrong address for change output",
inDeps: []*deposit.Deposit{s2d1}, // expect 500
outputs: []*wire.TxOut{
{
Value: 1337,
PkScript: serverAddr.PkScript,
},
{
Value: 500,
PkScript: otherAddr.PkScript,
},
},
addr: changeAddr,
expectErr: true,
expectedErrMsg: "couldn't find expected change",
},
{
name: "wrong amount for change output",
inDeps: []*deposit.Deposit{s2d1}, // expect 500
outputs: []*wire.TxOut{
{
Value: 1337,
PkScript: serverAddr.PkScript,
},
{
Value: 400,
PkScript: changeAddr.PkScript,
},
},
addr: changeAddr,
expectErr: true,
expectedErrMsg: "couldn't find expected change",
},
{
name: "mixed swaps some with change some without",
inDeps: []*deposit.Deposit{s1d1, s1d2, s3d1}, // A(0)+C(400)=400
outputs: []*wire.TxOut{
{
Value: 1337,
PkScript: serverAddr.PkScript,
},
{
Value: 400,
PkScript: changeAddr.PkScript,
},
{
Value: 1000,
PkScript: otherAddr.PkScript,
},
},
addr: changeAddr,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Prepare inputs mapping for deposit manager.
mdm := &mockDepositManager{
byOutpoint: map[string]*deposit.Deposit{},
}
inputs := make([]wire.OutPoint, 0, len(tc.inDeps))
for _, d := range tc.inDeps {
mdm.byOutpoint[outpointString(d)] = d
inputs = append(
inputs, wire.OutPoint{
Hash: d.Hash,
Index: d.Index,
},
)
}
mgr.cfg.DepositManager = mdm
tx := makeSweepTx(inputs, tc.outputs)
err := mgr.checkChange(ctx, tx, tc.addr)
if tc.expectErr {
require.Error(t, err)
if tc.expectedErrMsg != "" {
require.ErrorContains(
t, err, tc.expectedErrMsg,
)
}
} else {
require.NoError(t, err)
}
})
}
}