Skip to content

Commit f40ef19

Browse files
committed
loop: add migration for loopout swaps to fix negative stored costs
Previously we may have stored negative costs for some loop out swaps which this commit attempts to correct by fetching all completed swap, calculating the correct costs and overriding them in the database.
1 parent c650cdc commit f40ef19

File tree

4 files changed

+380
-3
lines changed

4 files changed

+380
-3
lines changed

cost_migration.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package loop
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/btcsuite/btcd/chaincfg"
9+
"github.com/lightninglabs/lndclient"
10+
"github.com/lightninglabs/loop/loopdb"
11+
"github.com/lightninglabs/loop/swap"
12+
"github.com/lightningnetwork/lnd/lntypes"
13+
"github.com/lightningnetwork/lnd/lnwire"
14+
)
15+
16+
const (
17+
costMigrationID = "cost_migration"
18+
)
19+
20+
// CalculateLoopOutCost calculates the total cost of a loop out swap. It will
21+
// correctly account for the on-chain and off-chain fees that were paid and
22+
// make sure that all costs are positive.
23+
func CalculateLoopOutCost(params *chaincfg.Params, loopOutSwap *loopdb.LoopOut,
24+
paymentFees map[lntypes.Hash]lnwire.MilliSatoshi) (loopdb.SwapCost,
25+
error) {
26+
27+
// First make sure that this swap is actually finished.
28+
if loopOutSwap.State().State.IsPending() {
29+
return loopdb.SwapCost{}, fmt.Errorf("swap is not yet finished")
30+
}
31+
32+
// We first need to decode the prepay invoice to get the prepay hash and
33+
// the prepay amount.
34+
_, _, hash, prepayAmount, err := swap.DecodeInvoice(
35+
params, loopOutSwap.Contract.PrepayInvoice,
36+
)
37+
if err != nil {
38+
return loopdb.SwapCost{}, fmt.Errorf("unable to decode the "+
39+
"prepay invoice: %v", err)
40+
}
41+
42+
// The swap hash is given and we don't need to get it from the
43+
// swap invoice, however we'll decode it anyway to get the invoice amount
44+
// that was paid in case we don't have the payment anymore.
45+
_, _, swapHash, swapPaymentAmount, err := swap.DecodeInvoice(
46+
params, loopOutSwap.Contract.SwapInvoice,
47+
)
48+
if err != nil {
49+
return loopdb.SwapCost{}, fmt.Errorf("unable to decode the "+
50+
"swap invoice: %v", err)
51+
}
52+
53+
var (
54+
cost loopdb.SwapCost
55+
swapPaid, prepayPaid bool
56+
)
57+
58+
// Now that we have the prepay and swap amount, we can calculate the
59+
// total cost of the swap. Note that we only need to account for the
60+
// server cost in case the swap was successful or if the sweep timed
61+
// out. Otherwise the server didn't pull the off-chain htlc nor the
62+
// prepay.
63+
switch loopOutSwap.State().State {
64+
case loopdb.StateSuccess:
65+
cost.Server = swapPaymentAmount + prepayAmount -
66+
loopOutSwap.Contract.AmountRequested
67+
68+
swapPaid = true
69+
prepayPaid = true
70+
71+
case loopdb.StateFailSweepTimeout:
72+
cost.Server = prepayAmount
73+
74+
prepayPaid = true
75+
76+
default:
77+
cost.Server = 0
78+
}
79+
80+
// Now attempt to look up the actual payments so we can calculate the
81+
// total routing costs.
82+
prepayPaymentFee, ok := paymentFees[hash]
83+
if prepayPaid && ok {
84+
cost.Offchain += prepayPaymentFee.ToSatoshis()
85+
} else {
86+
log.Debugf("Prepay payment %s is missing, won't account for "+
87+
"routing fees", hash)
88+
}
89+
90+
swapPaymentFee, ok := paymentFees[swapHash]
91+
if swapPaid && ok {
92+
cost.Offchain += swapPaymentFee.ToSatoshis()
93+
} else {
94+
log.Debugf("Swap payment %s is missing, won't account for "+
95+
"routing fees", swapHash)
96+
}
97+
98+
// For the on-chain cost, just make sure that the cost is positive.
99+
cost.Onchain = loopOutSwap.State().Cost.Onchain
100+
if cost.Onchain < 0 {
101+
cost.Onchain *= -1
102+
}
103+
104+
return cost, nil
105+
}
106+
107+
// MigrateLoopOutCosts will calculate the correct cost for all loop out swaps
108+
// and override the cost values of the last update in the database.
109+
func MigrateLoopOutCosts(ctx context.Context, lnd lndclient.LndServices,
110+
db loopdb.SwapStore) error {
111+
112+
migrationDone, err := db.HasMigration(ctx, costMigrationID)
113+
if err != nil {
114+
return err
115+
}
116+
if migrationDone {
117+
log.Infof("Cost cleanup migration already done, skipping")
118+
119+
return nil
120+
}
121+
122+
log.Infof("Starting cost cleanup migration")
123+
startTs := time.Now()
124+
defer func() {
125+
log.Infof("Finished cost cleanup migration in %v",
126+
time.Since(startTs))
127+
}()
128+
129+
// First we'll fetch all loop out swaps from the database.
130+
loopOutSwaps, err := db.FetchLoopOutSwaps(ctx)
131+
if err != nil {
132+
return err
133+
}
134+
135+
// Next we fetch all payments from LND.
136+
payments, err := lnd.Client.ListPayments(
137+
ctx, lndclient.ListPaymentsRequest{},
138+
)
139+
if err != nil {
140+
return err
141+
}
142+
143+
// Gather payment fees to a map for easier lookup.
144+
paymentFees := make(map[lntypes.Hash]lnwire.MilliSatoshi)
145+
for _, payment := range payments.Payments {
146+
paymentFees[payment.Hash] = payment.Fee
147+
}
148+
149+
// Now we'll calculate the cost for each swap and finally update the
150+
// costs in the database.
151+
updatedCosts := make(map[lntypes.Hash]loopdb.SwapCost)
152+
for _, loopOutSwap := range loopOutSwaps {
153+
cost, err := CalculateLoopOutCost(
154+
lnd.ChainParams, loopOutSwap, paymentFees,
155+
)
156+
if err != nil {
157+
return err
158+
}
159+
160+
_, ok := updatedCosts[loopOutSwap.Hash]
161+
if ok {
162+
return fmt.Errorf("found a duplicate swap %v while "+
163+
"updating costs", loopOutSwap.Hash)
164+
}
165+
166+
updatedCosts[loopOutSwap.Hash] = cost
167+
}
168+
169+
log.Infof("Updating costs for %d loop out swaps", len(updatedCosts))
170+
err = db.BatchUpdateLoopOutSwapCosts(ctx, updatedCosts)
171+
if err != nil {
172+
return err
173+
}
174+
175+
// Finally mark the migration as done.
176+
return db.SetMigration(ctx, costMigrationID)
177+
}

cost_migration_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package loop
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/btcsuite/btcd/btcutil"
9+
"github.com/lightninglabs/lndclient"
10+
"github.com/lightninglabs/loop/loopdb"
11+
"github.com/lightninglabs/loop/test"
12+
"github.com/lightningnetwork/lnd/lntypes"
13+
"github.com/lightningnetwork/lnd/lnwire"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// TestCalculateLoopOutCost tests the CalculateLoopOutCost function.
18+
func TestCalculateLoopOutCost(t *testing.T) {
19+
// Set up test context objects.
20+
lnd := test.NewMockLnd()
21+
server := newServerMock(lnd)
22+
store := loopdb.NewStoreMock(t)
23+
24+
cfg := &swapConfig{
25+
lnd: &lnd.LndServices,
26+
store: store,
27+
server: server,
28+
}
29+
30+
height := int32(600)
31+
req := *testRequest
32+
initResult, err := newLoopOutSwap(
33+
context.Background(), cfg, height, &req,
34+
)
35+
require.NoError(t, err)
36+
swap, err := store.FetchLoopOutSwap(
37+
context.Background(), initResult.swap.hash,
38+
)
39+
require.NoError(t, err)
40+
41+
// Override the chain cost so it's negative.
42+
const expectedChainCost = btcutil.Amount(1000)
43+
44+
// Now we have the swap and prepay invoices so let's calculate the
45+
// costs without providing the payments first, so we don't account for
46+
// any routing fees.
47+
paymentFees := make(map[lntypes.Hash]lnwire.MilliSatoshi)
48+
_, err = CalculateLoopOutCost(lnd.ChainParams, swap, paymentFees)
49+
50+
// We expect that the call fails as the swap isn't finished yet.
51+
require.Error(t, err)
52+
53+
// Override the swap state to make it look like the swap is finished
54+
// and make the chain cost negative too, so we can test that it'll be
55+
// corrected to be positive in the cost calculation.
56+
swap.Events = append(
57+
swap.Events, &loopdb.LoopEvent{
58+
SwapStateData: loopdb.SwapStateData{
59+
State: loopdb.StateSuccess,
60+
Cost: loopdb.SwapCost{
61+
Onchain: -expectedChainCost,
62+
},
63+
},
64+
},
65+
)
66+
costs, err := CalculateLoopOutCost(lnd.ChainParams, swap, paymentFees)
67+
require.NoError(t, err)
68+
69+
expectedServerCost := server.swapInvoiceAmt + server.prepayInvoiceAmt -
70+
swap.Contract.AmountRequested
71+
require.Equal(t, expectedServerCost, costs.Server)
72+
require.Equal(t, btcutil.Amount(0), costs.Offchain)
73+
require.Equal(t, expectedChainCost, costs.Onchain)
74+
75+
// Now add the two payments to the payments map and calculate the costs
76+
// again. We expect that the routng fees are now accounted for.
77+
paymentFees[server.swapHash] = lnwire.NewMSatFromSatoshis(44)
78+
paymentFees[server.prepayHash] = lnwire.NewMSatFromSatoshis(11)
79+
80+
costs, err = CalculateLoopOutCost(lnd.ChainParams, swap, paymentFees)
81+
require.NoError(t, err)
82+
83+
expectedOffchainCost := btcutil.Amount(44 + 11)
84+
require.Equal(t, expectedServerCost, costs.Server)
85+
require.Equal(t, expectedOffchainCost, costs.Offchain)
86+
require.Equal(t, expectedChainCost, costs.Onchain)
87+
88+
// Now override the last update to make the swap timed out at the HTLC
89+
// sweep. We expect that the chain cost won't change, and only the
90+
// prepay will be accounted for.
91+
swap.Events[0] = &loopdb.LoopEvent{
92+
SwapStateData: loopdb.SwapStateData{
93+
State: loopdb.StateFailSweepTimeout,
94+
Cost: loopdb.SwapCost{
95+
Onchain: 0,
96+
},
97+
},
98+
}
99+
100+
costs, err = CalculateLoopOutCost(lnd.ChainParams, swap, paymentFees)
101+
require.NoError(t, err)
102+
103+
expectedServerCost = server.prepayInvoiceAmt
104+
expectedOffchainCost = btcutil.Amount(11)
105+
require.Equal(t, expectedServerCost, costs.Server)
106+
require.Equal(t, expectedOffchainCost, costs.Offchain)
107+
require.Equal(t, btcutil.Amount(0), costs.Onchain)
108+
}
109+
110+
// TestCostMigration tests the cost migration for loop out swaps.
111+
func TestCostMigration(t *testing.T) {
112+
// Set up test context objects.
113+
lnd := test.NewMockLnd()
114+
server := newServerMock(lnd)
115+
store := loopdb.NewStoreMock(t)
116+
117+
cfg := &swapConfig{
118+
lnd: &lnd.LndServices,
119+
store: store,
120+
server: server,
121+
}
122+
123+
height := int32(600)
124+
req := *testRequest
125+
initResult, err := newLoopOutSwap(
126+
context.Background(), cfg, height, &req,
127+
)
128+
require.NoError(t, err)
129+
130+
// Override the chain cost so it's negative.
131+
const expectedChainCost = btcutil.Amount(1000)
132+
133+
// Override the swap state to make it look like the swap is finished
134+
// and make the chain cost negative too, so we can test that it'll be
135+
// corrected to be positive in the cost calculation.
136+
err = store.UpdateLoopOut(
137+
context.Background(), initResult.swap.hash, time.Now(),
138+
loopdb.SwapStateData{
139+
State: loopdb.StateSuccess,
140+
Cost: loopdb.SwapCost{
141+
Onchain: -expectedChainCost,
142+
},
143+
},
144+
)
145+
require.NoError(t, err)
146+
147+
// Add the two mocked payment to LND. Note that we only care about the
148+
// fees here, so we don't need to provide the full payment details.
149+
lnd.Payments = []lndclient.Payment{
150+
{
151+
Hash: server.swapHash,
152+
Fee: lnwire.NewMSatFromSatoshis(44),
153+
},
154+
{
155+
Hash: server.prepayHash,
156+
Fee: lnwire.NewMSatFromSatoshis(11),
157+
},
158+
}
159+
160+
// Now we can run the migration.
161+
err = MigrateLoopOutCosts(context.Background(), lnd.LndServices, store)
162+
require.NoError(t, err)
163+
164+
// Finally check that the swap cost has been updated correctly.
165+
swap, err := store.FetchLoopOutSwap(
166+
context.Background(), initResult.swap.hash,
167+
)
168+
require.NoError(t, err)
169+
170+
expectedServerCost := server.swapInvoiceAmt + server.prepayInvoiceAmt -
171+
swap.Contract.AmountRequested
172+
173+
costs := swap.Events[0].Cost
174+
expectedOffchainCost := btcutil.Amount(44 + 11)
175+
require.Equal(t, expectedServerCost, costs.Server)
176+
require.Equal(t, expectedOffchainCost, costs.Offchain)
177+
require.Equal(t, expectedChainCost, costs.Onchain)
178+
179+
// Now run the migration again to make sure it doesn't fail. This also
180+
// indicates that the migration did not run the second time as
181+
// otherwise the store mocks SetMigration function would fail.
182+
err = MigrateLoopOutCosts(context.Background(), lnd.LndServices, store)
183+
require.NoError(t, err)
184+
}

0 commit comments

Comments
 (0)