loop/staticaddr/loopin/manager_test.go
Slyghtning 6593afc8bc
staticaddr/loopin: recover risk-decision deadlines
Record replayed server risk decisions through the loop-in store,
recover accepted payment-deadline timers using the persisted decision
time, and handle persisted rejections on restart. This lets recovered
static loop-ins keep pending confirmation-risk state instead of
restarting payment timing from scratch.
2026-07-12 09:14:34 +02:00

833 lines
22 KiB
Go

package loopin
import (
"bytes"
"context"
"errors"
"testing"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/zpay32"
"github.com/stretchr/testify/require"
)
type testCase struct {
name string
deposits []*deposit.Deposit
targetValue btcutil.Amount
csvExpiry uint32
blockHeight uint32
expected []*deposit.Deposit
expectedErr string
}
// TestSelectDeposits tests the selectDeposits function, which selects
// deposits that can cover a target value while respecting the dust limit.
func TestSelectDeposits(t *testing.T) {
d1, d2, d3, d4 := &deposit.Deposit{
Value: 1_000_000,
ConfirmationHeight: 5_000,
}, &deposit.Deposit{
Value: 2_000_000,
ConfirmationHeight: 5_001,
}, &deposit.Deposit{
Value: 3_000_000,
ConfirmationHeight: 5_002,
}, &deposit.Deposit{
Value: 3_000_000,
ConfirmationHeight: 5_003,
}
d1.Hash = chainhash.Hash{1}
d1.Index = 0
d2.Hash = chainhash.Hash{2}
d2.Index = 0
d3.Hash = chainhash.Hash{3}
d3.Index = 0
d4.Hash = chainhash.Hash{4}
d4.Index = 0
testCases := []testCase{
{
name: "single deposit exact target",
deposits: []*deposit.Deposit{d1},
targetValue: 1_000_000,
expected: []*deposit.Deposit{d1},
expectedErr: "",
},
{
name: "prefer larger deposit when both cover",
deposits: []*deposit.Deposit{d1, d2},
targetValue: 1_000_000,
expected: []*deposit.Deposit{d2},
expectedErr: "",
},
{
name: "prefer largest among three when one is enough",
deposits: []*deposit.Deposit{d1, d2, d3},
targetValue: 1_000_000,
expected: []*deposit.Deposit{d3},
expectedErr: "",
},
{
name: "prefer confirmed deposit over larger unconfirmed one",
deposits: []*deposit.Deposit{
{
Value: 2_000_000,
ConfirmationHeight: 0,
},
{
Value: 1_500_000,
ConfirmationHeight: 5_004,
},
},
targetValue: 1_000_000,
expected: []*deposit.Deposit{
{
Value: 1_500_000,
ConfirmationHeight: 5_004,
},
},
expectedErr: "",
},
{
name: "single deposit insufficient by 1",
deposits: []*deposit.Deposit{d1},
targetValue: 1_000_001,
expected: []*deposit.Deposit{},
expectedErr: "not enough deposits to cover",
},
{
name: "target leaves exact dust limit change",
deposits: []*deposit.Deposit{d1},
targetValue: 1_000_000 - dustLimit,
expected: []*deposit.Deposit{d1},
expectedErr: "",
},
{
name: "target leaves dust change (just over)",
deposits: []*deposit.Deposit{d1},
targetValue: 1_000_000 - dustLimit + 1,
expected: []*deposit.Deposit{},
expectedErr: "not enough deposits to cover",
},
{
name: "all deposits exactly match target",
deposits: []*deposit.Deposit{d1, d2, d3},
targetValue: d1.Value + d2.Value + d3.Value,
expected: []*deposit.Deposit{d1, d2, d3},
expectedErr: "",
},
{
name: "sum minus dust limit is allowed (change == dust)",
deposits: []*deposit.Deposit{d1, d2, d3},
targetValue: d1.Value + d2.Value + d3.Value - dustLimit,
expected: []*deposit.Deposit{d1, d2, d3},
expectedErr: "",
},
{
name: "sum minus dust limit plus 1 is not allowed (dust change)",
deposits: []*deposit.Deposit{d1, d2, d3},
targetValue: d1.Value + d2.Value + d3.Value - dustLimit + 1,
expected: []*deposit.Deposit{},
expectedErr: "not enough deposits to cover",
},
{
name: "tie by value, prefer earlier expiry",
deposits: []*deposit.Deposit{d3, d4},
targetValue: d4.Value - dustLimit, // d3/d4 have the
// same value but different expiration.
expected: []*deposit.Deposit{d3},
expectedErr: "",
},
{
name: "prefilter filters deposits close to expiry",
deposits: func() []*deposit.Deposit {
// dClose expires before
// htlcExpiry+DepositHtlcDelta and must be
// filtered out. dOK expires exactly at the
// threshold and must be eligible.
dClose := &deposit.Deposit{
Value: 3_000_000,
ConfirmationHeight: 3000,
}
dClose.Hash = chainhash.Hash{5}
dClose.Index = 0
dOK := &deposit.Deposit{
Value: 2_000_000,
ConfirmationHeight: 3050,
}
dOK.Hash = chainhash.Hash{6}
dOK.Index = 0
return []*deposit.Deposit{dClose, dOK}
}(),
targetValue: 1_000_000,
csvExpiry: 1000,
blockHeight: 3000,
expected: func() []*deposit.Deposit {
// Only dOK should be considered.
// dClose is filtered.
dOK := &deposit.Deposit{
Value: 2_000_000,
ConfirmationHeight: 3050,
}
dOK.Hash = chainhash.Hash{6}
dOK.Index = 0
return []*deposit.Deposit{dOK}
}(),
expectedErr: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
selectedDeposits, err := SelectDeposits(
tc.targetValue, tc.deposits, tc.csvExpiry,
tc.blockHeight,
)
if tc.expectedErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tc.expectedErr)
}
require.ElementsMatch(t, tc.expected, selectedDeposits)
})
}
}
// TestInitiateLoopInAllowsReservedAutoloopLabel verifies that the internal
// loop-in manager path does not reject reserved autoloop labels. The RPC
// boundary owns that validation, while internal autoloop dispatch must be able
// to reuse the reserved labels directly.
func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) {
ctx := t.Context()
const confirmationHeight = 0
selectedDeposit := makeDeposit(1, 0, 9_000, confirmationHeight)
selectedOutpoint := selectedDeposit.OutPoint.String()
quoteErr := errors.New("quote failed")
quoteGetter := &mockQuoteGetter{
err: quoteErr,
}
manager, err := NewManager(&Config{
DepositManager: &mockDepositManager{
byOutpoint: map[string]*deposit.Deposit{
selectedOutpoint: selectedDeposit,
},
},
QuoteGetter: quoteGetter,
NodePubkey: route.Vertex{2},
}, 200)
require.NoError(t, err)
_, err = manager.initiateLoopIn(ctx, &loop.StaticAddressLoopInRequest{
DepositOutpoints: []string{selectedOutpoint},
SelectedAmount: selectedDeposit.Value,
MaxSwapFee: 1_000,
Label: labels.AutoloopLabel(swap.TypeIn),
Initiator: "autoloop",
})
require.ErrorIs(t, err, quoteErr)
require.NotContains(t, err.Error(), labels.ErrReservedPrefix.Error())
require.Equal(t, selectedDeposit.Value, quoteGetter.amount)
}
// TestHandleLoopInSweepReqRejectsInvalidServerNonce ensures that a malformed
// MuSig2 nonce returned by the server is rejected before it reaches the signer.
func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) {
ctx := t.Context()
changeAddr := &script.Parameters{
PkScript: []byte{0xaa, 0xbb},
}
const confirmationHeight = 0
dep := makeDeposit(7, 0, 10_000, confirmationHeight)
depOutpoint := outpointString(dep)
swapHash := lntypes.Hash{9}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
DepositOutpoints: []string{depOutpoint},
SelectedAmount: dep.Value,
}
loopIn.SetState(Succeeded)
sweepTx := makeSweepTx(
[]wire.OutPoint{dep.OutPoint},
[]*wire.TxOut{{
Value: int64(dep.Value),
PkScript: []byte{0xcc, 0xdd},
}},
)
sweepPacket, err := psbt.NewFromUnsignedTx(sweepTx)
require.NoError(t, err)
var psbtBuf bytes.Buffer
require.NoError(t, sweepPacket.Serialize(&psbtBuf))
mgr := &Manager{
cfg: &Config{
AddressManager: &mockAddressManager{
params: changeAddr,
},
DepositManager: &mockDepositManager{
byOutpoint: map[string]*deposit.Deposit{
depOutpoint: dep,
},
},
Store: &mockStore{
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
swapHash: loopIn,
},
mapIDs: map[lntypes.Hash][]deposit.ID{
swapHash: {dep.ID},
},
},
},
}
req := &swapserverrpc.ServerStaticLoopInSweepNotification{
SweepTxPsbt: psbtBuf.Bytes(),
SwapHash: swapHash[:],
DepositToNonces: map[string][]byte{
depOutpoint: make([]byte, musig2.PubNonceSize-1),
},
PrevoutInfo: []*swapserverrpc.PrevoutInfo{{
Value: uint64(dep.Value),
PkScript: changeAddr.PkScript,
TxidBytes: dep.Hash[:],
OutputIndex: dep.Index,
}},
}
err = mgr.handleLoopInSweepReq(ctx, req)
require.ErrorContains(t, err, "invalid server nonce")
require.ErrorContains(t, err, depOutpoint)
}
// TestActiveDepositsForLoopInUsesCurrentDepositOutpoints verifies that
// recovery checks the current deposit outpoints reconstructed by the store
// rather than the original outpoint snapshot persisted on the swap.
func TestActiveDepositsForLoopInUsesCurrentDepositOutpoints(t *testing.T) {
oldOutpoint := wire.OutPoint{
Hash: chainhash.Hash{0xaa},
Index: 0,
}
currentDeposit := makeDeposit(0xbb, 1, 10_000, 42)
manager := &Manager{
cfg: &Config{
DepositManager: &mockDepositManager{
byOutpoint: map[string]*deposit.Deposit{
currentDeposit.OutPoint.String(): currentDeposit,
},
},
},
}
deposits, allActive := manager.activeDepositsForLoopIn(
&StaticAddressLoopIn{
DepositOutpoints: []string{oldOutpoint.String()},
Deposits: []*deposit.Deposit{currentDeposit},
},
)
require.True(t, allActive)
require.Equal(t, []*deposit.Deposit{currentDeposit}, deposits)
}
// TestGetAllSwapsPreservesStoreDeposits verifies that list responses keep the
// store's swap_hash/deposit-id reconstruction even when DepositOutpoints is an
// original input snapshot and the deposit's current outpoint has changed.
func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) {
oldOutpoint := wire.OutPoint{
Hash: chainhash.Hash{0xcc},
Index: 0,
}
currentDeposit := makeDeposit(0xdd, 1, 10_000, 42)
swap := &StaticAddressLoopIn{
DepositOutpoints: []string{oldOutpoint.String()},
Deposits: []*deposit.Deposit{currentDeposit},
}
manager := &Manager{
cfg: &Config{
Store: &mockStore{
swaps: []*StaticAddressLoopIn{swap},
},
},
}
swaps, err := manager.GetAllSwaps(t.Context())
require.NoError(t, err)
require.Len(t, swaps, 1)
require.Equal(t, []string{oldOutpoint.String()}, swaps[0].DepositOutpoints)
require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits)
}
// TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered
// swappable because its CSV timeout has not started yet.
func TestIsSwappableUnconfirmed(t *testing.T) {
require.True(t, IsSwappable(0, 5000, 1000))
}
// mockDepositManager implements DepositManager for tests.
type mockDepositManager struct {
// activeDeposits is the set returned by GetActiveDepositsInState.
activeDeposits []*deposit.Deposit
// byOutpoint maps outpoint strings to deposits for direct lookups.
byOutpoint map[string]*deposit.Deposit
}
func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error {
return nil
}
func (m *mockDepositManager) GetAllDeposits(_ context.Context) (
[]*deposit.Deposit, error) {
return nil, nil
}
func (m *mockDepositManager) AllStringOutpointsActiveDeposits(outpoints []string,
state fsm.StateType) ([]*deposit.Deposit, bool) {
if state != deposit.Deposited && state != fsm.EmptyState {
return nil, false
}
if m.byOutpoint == nil {
return nil, false
}
res := make([]*deposit.Deposit, 0, len(outpoints))
for _, outpoint := range outpoints {
selectedDeposit, ok := m.byOutpoint[outpoint]
if !ok {
return nil, false
}
res = append(res, selectedDeposit)
}
return res, true
}
func (m *mockDepositManager) TransitionDeposits(_ context.Context,
_ []*deposit.Deposit, _ fsm.EventType, _ fsm.StateType) error {
return nil
}
func (m *mockDepositManager) DepositsForOutpoints(_ context.Context,
outpoints []string, ignoreUnknown bool) ([]*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 m.activeDeposits, nil
}
// mockQuoteGetter records the inputs to quote requests and returns a fixed
// loop-in quote.
type mockQuoteGetter struct {
// quote is the response returned from GetLoopInQuote.
quote *loop.LoopInQuote
// err is the optional error returned from GetLoopInQuote.
err error
// amount records the quoted amount.
amount btcutil.Amount
// lastHop records the quoted last hop.
lastHop *route.Vertex
// initiator records the quoted initiator string.
initiator string
// numDeposits records the quoted deposit count.
numDeposits uint32
// fast records the quoted fast flag.
fast bool
}
// GetLoopInQuote returns the configured quote and records the request
// parameters for assertions.
func (m *mockQuoteGetter) GetLoopInQuote(_ context.Context,
amt btcutil.Amount, _ route.Vertex, lastHop *route.Vertex,
_ [][]zpay32.HopHint, initiator string, numDeposits uint32,
fast bool) (*loop.LoopInQuote, error) {
m.amount = amt
m.lastHop = lastHop
m.initiator = initiator
m.numDeposits = numDeposits
m.fast = fast
if m.err != nil {
return nil, m.err
}
return m.quote, nil
}
// mockStore implements StaticAddressLoopInStore for tests.
type mockStore struct {
swaps []*StaticAddressLoopIn
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 s.swaps, nil
}
func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) {
return false, nil
}
// RecordStaticAddressRiskDecision implements Store for manager tests.
func (s *mockStore) RecordStaticAddressRiskDecision(context.Context,
lntypes.Hash, ConfirmationRiskDecision) error {
return 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,
confirmationHeight int64) *deposit.Deposit {
d := &deposit.Deposit{
Value: value,
ConfirmationHeight: confirmationHeight,
}
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 := &script.Parameters{PkScript: []byte{0xaa, 0xbb}}
otherAddr := &script.Parameters{PkScript: []byte{0xcc, 0xdd}}
serverAddr := &script.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.
const confirmationHeight = 0
s1d1 := makeDeposit(1, 0, 1000, confirmationHeight)
s1d2 := makeDeposit(1, 1, 2000, confirmationHeight)
s2d1 := makeDeposit(2, 0, 1500, confirmationHeight)
s3d1 := makeDeposit(3, 0, 800, confirmationHeight)
s4d1 := makeDeposit(4, 0, 900, confirmationHeight)
// 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 *script.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)
}
})
}
}