Skip to content

Commit 55241ff

Browse files
authored
Merge pull request #764 from bhandras/costs-cleanup-migration
loop: add migration to fix stored loop out costs
2 parents 37194d3 + c7fa1e4 commit 55241ff

18 files changed

+805
-8
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+
}

loopd/daemon.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,12 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
409409
return err
410410
}
411411

412+
// Run the costs migration.
413+
err = loop.MigrateLoopOutCosts(d.mainCtx, d.lnd.LndServices, swapDb)
414+
if err != nil {
415+
return err
416+
}
417+
412418
sweeperDb := sweepbatcher.NewSQLStore(baseDb, chainParams)
413419

414420
// Create an instance of the loop client library.

loopdb/interface.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ type SwapStore interface {
6565
// it's decoding using the proto package's `Unmarshal` method.
6666
FetchLiquidityParams(ctx context.Context) ([]byte, error)
6767

68+
// BatchUpdateLoopOutSwapCosts updates the swap costs for a batch of
69+
// loop out swaps.
70+
BatchUpdateLoopOutSwapCosts(ctx context.Context,
71+
swaps map[lntypes.Hash]SwapCost) error
72+
73+
// HasMigration returns true if the migration with the given ID has
74+
// been done.
75+
HasMigration(ctx context.Context, migrationID string) (bool, error)
76+
77+
// SetMigration marks the migration with the given ID as done.
78+
SetMigration(ctx context.Context, migrationID string) error
79+
6880
// Close closes the underlying database.
6981
Close() error
7082
}

0 commit comments

Comments
 (0)