Merge pull request #2124 from cfromknecht/wtlookout
[watchtower/lookout]: on-chain breach monitoring
This commit is contained in:
12 files changed
+1553
-50
No files matched your search
@@ -2,6 +2,7 @@ package blob
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -22,8 +23,8 @@ const (
|
|||||||
// MaxVersion is the maximumm blob version supported by this package.
|
// MaxVersion is the maximumm blob version supported by this package.
|
||||||
MaxVersion = 0
|
MaxVersion = 0
|
||||||
|
|
||||||
// NonceSize is the length of a chacha20poly1305 nonce, 12 bytes.
|
// NonceSize is the length of a chacha20poly1305 nonce, 24 bytes.
|
||||||
NonceSize = chacha20poly1305.NonceSize
|
NonceSize = chacha20poly1305.NonceSizeX
|
||||||
|
|
||||||
// KeySize is the length of a chacha20poly1305 key, 32 bytes.
|
// KeySize is the length of a chacha20poly1305 key, 32 bytes.
|
||||||
KeySize = chacha20poly1305.KeySize
|
KeySize = chacha20poly1305.KeySize
|
||||||
@@ -49,10 +50,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Size returns the size of the encoded-and-encrypted blob in bytes.
|
// Size returns the size of the encoded-and-encrypted blob in bytes.
|
||||||
// enciphered plaintext: n bytes
|
// nonce: 24 bytes
|
||||||
// MAC: 16 bytes
|
// enciphered plaintext: n bytes
|
||||||
|
// MAC: 16 bytes
|
||||||
func Size(ver uint16) int {
|
func Size(ver uint16) int {
|
||||||
return PlaintextSize(ver) + CiphertextExpansion
|
return NonceSize + PlaintextSize(ver) + CiphertextExpansion
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlaintextSize returns the size of the encoded-but-unencrypted blob in bytes.
|
// PlaintextSize returns the size of the encoded-but-unencrypted blob in bytes.
|
||||||
@@ -79,11 +81,6 @@ var (
|
|||||||
"ciphertext is too small for chacha20poly1305",
|
"ciphertext is too small for chacha20poly1305",
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNonceSize signals that the provided nonce is improperly sized.
|
|
||||||
ErrNonceSize = fmt.Errorf(
|
|
||||||
"chacha20poly1305 nonce must be %d bytes", NonceSize,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrKeySize signals that the provided key is improperly sized.
|
// ErrKeySize signals that the provided key is improperly sized.
|
||||||
ErrKeySize = fmt.Errorf(
|
ErrKeySize = fmt.Errorf(
|
||||||
"chacha20poly1305 key size must be %d bytes", KeySize,
|
"chacha20poly1305 key size must be %d bytes", KeySize,
|
||||||
@@ -180,12 +177,18 @@ func (b *JusticeKit) CommitToLocalWitnessScript() ([]byte, error) {
|
|||||||
// CommitToLocalRevokeWitnessStack constructs a witness stack spending the
|
// CommitToLocalRevokeWitnessStack constructs a witness stack spending the
|
||||||
// revocation clause of the commitment to-local output.
|
// revocation clause of the commitment to-local output.
|
||||||
// <revocation-sig> 1
|
// <revocation-sig> 1
|
||||||
func (b *JusticeKit) CommitToLocalRevokeWitnessStack() [][]byte {
|
func (b *JusticeKit) CommitToLocalRevokeWitnessStack() ([][]byte, error) {
|
||||||
|
toLocalSig, err := b.CommitToLocalSig.ToSignature()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
witnessStack := make([][]byte, 2)
|
witnessStack := make([][]byte, 2)
|
||||||
witnessStack[0] = append(b.CommitToLocalSig[:], byte(txscript.SigHashAll))
|
witnessStack[0] = append(toLocalSig.Serialize(),
|
||||||
|
byte(txscript.SigHashAll))
|
||||||
witnessStack[1] = []byte{1}
|
witnessStack[1] = []byte{1}
|
||||||
|
|
||||||
return witnessStack
|
return witnessStack, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasCommitToRemoteOutput returns true if the blob contains a to-remote p2wkh
|
// HasCommitToRemoteOutput returns true if the blob contains a to-remote p2wkh
|
||||||
@@ -207,11 +210,17 @@ func (b *JusticeKit) CommitToRemoteWitnessScript() ([]byte, error) {
|
|||||||
// CommitToRemoteWitnessStack returns a witness stack spending the commitment
|
// CommitToRemoteWitnessStack returns a witness stack spending the commitment
|
||||||
// to-remote output, which is a regular p2wkh.
|
// to-remote output, which is a regular p2wkh.
|
||||||
// <to-remote-sig>
|
// <to-remote-sig>
|
||||||
func (b *JusticeKit) CommitToRemoteWitnessStack() [][]byte {
|
func (b *JusticeKit) CommitToRemoteWitnessStack() ([][]byte, error) {
|
||||||
witnessStack := make([][]byte, 1)
|
toRemoteSig, err := b.CommitToRemoteSig.ToSignature()
|
||||||
witnessStack[0] = append(b.CommitToRemoteSig[:], byte(txscript.SigHashAll))
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return witnessStack
|
witnessStack := make([][]byte, 1)
|
||||||
|
witnessStack[0] = append(toRemoteSig.Serialize(),
|
||||||
|
byte(txscript.SigHashAll))
|
||||||
|
|
||||||
|
return witnessStack, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt encodes the blob of justice using encoding version, and then
|
// Encrypt encodes the blob of justice using encoding version, and then
|
||||||
@@ -220,15 +229,9 @@ func (b *JusticeKit) CommitToRemoteWitnessStack() [][]byte {
|
|||||||
//
|
//
|
||||||
// NOTE: It is the caller's responsibility to ensure that this method is only
|
// NOTE: It is the caller's responsibility to ensure that this method is only
|
||||||
// called once for a given (nonce, key) pair.
|
// called once for a given (nonce, key) pair.
|
||||||
func (b *JusticeKit) Encrypt(nonce, key []byte, version uint16) ([]byte, error) {
|
func (b *JusticeKit) Encrypt(key []byte, version uint16) ([]byte, error) {
|
||||||
switch {
|
|
||||||
|
|
||||||
// Fail if the nonce is not 12-bytes.
|
|
||||||
case len(nonce) != NonceSize:
|
|
||||||
return nil, ErrNonceSize
|
|
||||||
|
|
||||||
// Fail if the nonce is not 32-bytes.
|
// Fail if the nonce is not 32-bytes.
|
||||||
case len(key) != KeySize:
|
if len(key) != KeySize {
|
||||||
return nil, ErrKeySize
|
return nil, ErrKeySize
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,19 +244,25 @@ func (b *JusticeKit) Encrypt(nonce, key []byte, version uint16) ([]byte, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a new chacha20poly1305 cipher, using a 32-byte key.
|
// Create a new chacha20poly1305 cipher, using a 32-byte key.
|
||||||
cipher, err := chacha20poly1305.New(key)
|
cipher, err := chacha20poly1305.NewX(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allocate the ciphertext, which will contain the encrypted plaintext
|
// Allocate the ciphertext, which will contain the nonce, encrypted
|
||||||
// and MAC.
|
// plaintext and MAC.
|
||||||
plaintext := ptxtBuf.Bytes()
|
plaintext := ptxtBuf.Bytes()
|
||||||
ciphertext := make([]byte, len(plaintext)+CiphertextExpansion)
|
ciphertext := make([]byte, Size(version))
|
||||||
|
|
||||||
|
// Generate a random 24-byte nonce in the ciphertext's prefix.
|
||||||
|
nonce := ciphertext[:NonceSize]
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Finally, encrypt the plaintext using the given nonce, storing the
|
// Finally, encrypt the plaintext using the given nonce, storing the
|
||||||
// result in the ciphertext buffer.
|
// result in the ciphertext buffer.
|
||||||
cipher.Seal(ciphertext[:0], nonce, plaintext, nil)
|
cipher.Seal(ciphertext[NonceSize:NonceSize], nonce, plaintext, nil)
|
||||||
|
|
||||||
return ciphertext, nil
|
return ciphertext, nil
|
||||||
}
|
}
|
||||||
@@ -261,24 +270,21 @@ func (b *JusticeKit) Encrypt(nonce, key []byte, version uint16) ([]byte, error)
|
|||||||
// Decrypt unenciphers a blob of justice by decrypting the ciphertext using
|
// Decrypt unenciphers a blob of justice by decrypting the ciphertext using
|
||||||
// chacha20poly1305 with the chosen (nonce, key) pair. The internal plaintext is
|
// chacha20poly1305 with the chosen (nonce, key) pair. The internal plaintext is
|
||||||
// then deserialized using the given encoding version.
|
// then deserialized using the given encoding version.
|
||||||
func Decrypt(nonce, key, ciphertext []byte, version uint16) (*JusticeKit, error) {
|
func Decrypt(key, ciphertext []byte, version uint16) (*JusticeKit, error) {
|
||||||
switch {
|
switch {
|
||||||
|
|
||||||
// Fail if the blob's overall length is less than the expansion factor.
|
// Fail if the blob's overall length is less than required for the nonce
|
||||||
case len(ciphertext) < CiphertextExpansion:
|
// and expansion factor.
|
||||||
|
case len(ciphertext) < NonceSize+CiphertextExpansion:
|
||||||
return nil, ErrCiphertextTooSmall
|
return nil, ErrCiphertextTooSmall
|
||||||
|
|
||||||
// Fail if the nonce is not 12-bytes.
|
|
||||||
case len(nonce) != NonceSize:
|
|
||||||
return nil, ErrNonceSize
|
|
||||||
|
|
||||||
// Fail if the key is not 32-bytes.
|
// Fail if the key is not 32-bytes.
|
||||||
case len(key) != KeySize:
|
case len(key) != KeySize:
|
||||||
return nil, ErrKeySize
|
return nil, ErrKeySize
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new chacha20poly1305 cipher, using a 32-byte key.
|
// Create a new chacha20poly1305 cipher, using a 32-byte key.
|
||||||
cipher, err := chacha20poly1305.New(key)
|
cipher, err := chacha20poly1305.NewX(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -290,7 +296,8 @@ func Decrypt(nonce, key, ciphertext []byte, version uint16) (*JusticeKit, error)
|
|||||||
|
|
||||||
// Decrypt the ciphertext, placing the resulting plaintext in our
|
// Decrypt the ciphertext, placing the resulting plaintext in our
|
||||||
// plaintext buffer.
|
// plaintext buffer.
|
||||||
_, err = cipher.Open(plaintext[:0], nonce, ciphertext, nil)
|
nonce := ciphertext[:NonceSize]
|
||||||
|
_, err = cipher.Open(plaintext[:0], nonce, ciphertext[NonceSize:], nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,15 +163,9 @@ func testBlobJusticeKitEncryptDecrypt(t *testing.T, test descriptorTest) {
|
|||||||
t.Fatalf("unable to generate blob encryption key: %v", err)
|
t.Fatalf("unable to generate blob encryption key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce := make([]byte, blob.NonceSize)
|
|
||||||
_, err = io.ReadFull(rand.Reader, nonce)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unable to generate nonce nonce: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt the blob plaintext using the generated key and
|
// Encrypt the blob plaintext using the generated key and
|
||||||
// target version for this test.
|
// target version for this test.
|
||||||
ctxt, err := boj.Encrypt(nonce, key, test.encVersion)
|
ctxt, err := boj.Encrypt(key, test.encVersion)
|
||||||
if err != test.encErr {
|
if err != test.encErr {
|
||||||
t.Fatalf("unable to encrypt blob: %v", err)
|
t.Fatalf("unable to encrypt blob: %v", err)
|
||||||
} else if test.encErr != nil {
|
} else if test.encErr != nil {
|
||||||
@@ -191,7 +185,7 @@ func testBlobJusticeKitEncryptDecrypt(t *testing.T, test descriptorTest) {
|
|||||||
// Decrypt the encrypted blob, reconstructing the original
|
// Decrypt the encrypted blob, reconstructing the original
|
||||||
// blob plaintext from the decrypted contents. We use the target
|
// blob plaintext from the decrypted contents. We use the target
|
||||||
// decryption version specified by this test case.
|
// decryption version specified by this test case.
|
||||||
boj2, err := blob.Decrypt(nonce, key, ctxt, test.decVersion)
|
boj2, err := blob.Decrypt(key, ctxt, test.decVersion)
|
||||||
if err != test.decErr {
|
if err != test.decErr {
|
||||||
t.Fatalf("unable to decrypt blob: %v", err)
|
t.Fatalf("unable to decrypt blob: %v", err)
|
||||||
} else if test.decErr != nil {
|
} else if test.decErr != nil {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package lookout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||||
|
"github.com/btcsuite/btcd/wire"
|
||||||
|
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/wtdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service abstracts the lookout functionality, supporting the ability to start
|
||||||
|
// and stop. All communication and actions are driven via the database or chain
|
||||||
|
// events.
|
||||||
|
type Service interface {
|
||||||
|
// Start safely starts up the Interface.
|
||||||
|
Start() error
|
||||||
|
|
||||||
|
// Stop safely stops the Interface.
|
||||||
|
Stop() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockFetcher supports the ability to fetch blocks from the backend or
|
||||||
|
// network.
|
||||||
|
type BlockFetcher interface {
|
||||||
|
// GetBlock fetches the block given the target block hash.
|
||||||
|
GetBlock(*chainhash.Hash) (*wire.MsgBlock, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB abstracts the required persistent calls expected by the lookout. DB
|
||||||
|
// provides the ability to search for state updates that correspond to breach
|
||||||
|
// transactions confirmed in a particular block.
|
||||||
|
type DB interface {
|
||||||
|
// GetLookoutTip returns the last block epoch at which the tower
|
||||||
|
// performed a match. If no match has been done, a nil epoch will be
|
||||||
|
// returned.
|
||||||
|
GetLookoutTip() (*chainntnfs.BlockEpoch, error)
|
||||||
|
|
||||||
|
// QueryMatches searches its database for any state updates matching the
|
||||||
|
// provided breach hints. If any matches are found, they will be
|
||||||
|
// returned along with encrypted blobs so that justice can be exacted.
|
||||||
|
QueryMatches([]wtdb.BreachHint) ([]wtdb.Match, error)
|
||||||
|
|
||||||
|
// SetLookoutTip writes the best epoch for which the watchtower has
|
||||||
|
// queried for breach hints.
|
||||||
|
SetLookoutTip(*chainntnfs.BlockEpoch) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// EpochRegistrar supports the ability to register for events corresponding to
|
||||||
|
// newly created blocks.
|
||||||
|
type EpochRegistrar interface {
|
||||||
|
// RegisterBlockEpochNtfn registers for a new block epoch subscription.
|
||||||
|
// The implementation must support historical dispatch, starting from
|
||||||
|
// the provided chainntnfs.BlockEpoch when it is non-nil. The
|
||||||
|
// notifications should be delivered in-order, and deliver reorged
|
||||||
|
// blocks.
|
||||||
|
RegisterBlockEpochNtfn(
|
||||||
|
*chainntnfs.BlockEpoch) (*chainntnfs.BlockEpochEvent, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Punisher handles the construction and publication of justice transactions
|
||||||
|
// once they have been detected by the Service.
|
||||||
|
type Punisher interface {
|
||||||
|
// Punish accepts a JusticeDescriptor, constructs the justice
|
||||||
|
// transaction, and publishes the transaction to the network so it can
|
||||||
|
// be mined. The second parameter is a quit channel so that long-running
|
||||||
|
// operations required to track the confirmation of the transaction can
|
||||||
|
// be canceled on shutdown.
|
||||||
|
Punish(*JusticeDescriptor, <-chan struct{}) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package lookout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/blockchain"
|
||||||
|
"github.com/btcsuite/btcd/btcec"
|
||||||
|
"github.com/btcsuite/btcd/txscript"
|
||||||
|
"github.com/btcsuite/btcd/wire"
|
||||||
|
"github.com/btcsuite/btcutil"
|
||||||
|
"github.com/lightningnetwork/lnd/lnwallet"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/blob"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/wtdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrOutputNotFound signals that the breached output could not be found
|
||||||
|
// on the commitment transaction.
|
||||||
|
ErrOutputNotFound = errors.New("unable to find output on commit tx")
|
||||||
|
|
||||||
|
// ErrUnknownSweepAddrType signals that client provided an output that
|
||||||
|
// was not p2wkh or p2wsh.
|
||||||
|
ErrUnknownSweepAddrType = errors.New("sweep addr is not p2wkh or p2wsh")
|
||||||
|
)
|
||||||
|
|
||||||
|
// JusticeDescriptor contains the information required to sweep a breached
|
||||||
|
// channel on behalf of a victim. It supports the ability to create the justice
|
||||||
|
// transaction that sweeps the commitments and recover a cut of the channel for
|
||||||
|
// the watcher's eternal vigilance.
|
||||||
|
type JusticeDescriptor struct {
|
||||||
|
// BreachedCommitTx is the commitment transaction that caused the breach
|
||||||
|
// to be detected.
|
||||||
|
BreachedCommitTx *wire.MsgTx
|
||||||
|
|
||||||
|
// SessionInfo contains the contract with the watchtower client and
|
||||||
|
// the prenegotiated terms they agreed to.
|
||||||
|
SessionInfo *wtdb.SessionInfo
|
||||||
|
|
||||||
|
// JusticeKit contains the decrypted blob and information required to
|
||||||
|
// construct the transaction scripts and witnesses.
|
||||||
|
JusticeKit *blob.JusticeKit
|
||||||
|
}
|
||||||
|
|
||||||
|
// breachedInput contains the required information to construct and spend
|
||||||
|
// breached outputs on a commitment transaction.
|
||||||
|
type breachedInput struct {
|
||||||
|
txOut *wire.TxOut
|
||||||
|
outPoint wire.OutPoint
|
||||||
|
witness [][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitToLocalInput extracts the information required to spend the commit
|
||||||
|
// to-local output.
|
||||||
|
func (p *JusticeDescriptor) commitToLocalInput() (*breachedInput, error) {
|
||||||
|
// Retrieve the to-local witness script from the justice kit.
|
||||||
|
toLocalScript, err := p.JusticeKit.CommitToLocalWitnessScript()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the witness script hash, which will be used to locate the
|
||||||
|
// input on the breaching commitment transaction.
|
||||||
|
toLocalWitnessHash, err := lnwallet.WitnessScriptHash(toLocalScript)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the to-local output on the breaching commitment transaction.
|
||||||
|
toLocalIndex, toLocalTxOut, err := findTxOutByPkScript(
|
||||||
|
p.BreachedCommitTx, toLocalWitnessHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the to-local outpoint that will be spent in the justice
|
||||||
|
// transaction.
|
||||||
|
toLocalOutPoint := wire.OutPoint{
|
||||||
|
Hash: p.BreachedCommitTx.TxHash(),
|
||||||
|
Index: toLocalIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve to-local witness stack, which primarily includes a signature
|
||||||
|
// under the revocation pubkey.
|
||||||
|
witnessStack, err := p.JusticeKit.CommitToLocalRevokeWitnessStack()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &breachedInput{
|
||||||
|
txOut: toLocalTxOut,
|
||||||
|
outPoint: toLocalOutPoint,
|
||||||
|
witness: buildWitness(witnessStack, toLocalScript),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitToRemoteInput extracts the information required to spend the commit
|
||||||
|
// to-remote output.
|
||||||
|
func (p *JusticeDescriptor) commitToRemoteInput() (*breachedInput, error) {
|
||||||
|
// Retrieve the to-remote witness script from the justice kit.
|
||||||
|
toRemoteScript, err := p.JusticeKit.CommitToRemoteWitnessScript()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since the to-remote witness script should just be a regular p2wkh
|
||||||
|
// output, we'll parse it to retrieve the public key.
|
||||||
|
toRemotePubKey, err := btcec.ParsePubKey(toRemoteScript, btcec.S256())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the witness script hash from the to-remote pubkey, which will
|
||||||
|
// be used to locate the input on the breach commitment transaction.
|
||||||
|
toRemoteScriptHash, err := lnwallet.CommitScriptUnencumbered(
|
||||||
|
toRemotePubKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the to-remote output on the breaching commitment transaction.
|
||||||
|
toRemoteIndex, toRemoteTxOut, err := findTxOutByPkScript(
|
||||||
|
p.BreachedCommitTx, toRemoteScriptHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the to-remote outpoint which will be spent in the justice
|
||||||
|
// transaction.
|
||||||
|
toRemoteOutPoint := wire.OutPoint{
|
||||||
|
Hash: p.BreachedCommitTx.TxHash(),
|
||||||
|
Index: toRemoteIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the to-remote witness stack, which is just a signature under
|
||||||
|
// the to-remote pubkey.
|
||||||
|
witnessStack, err := p.JusticeKit.CommitToRemoteWitnessStack()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &breachedInput{
|
||||||
|
txOut: toRemoteTxOut,
|
||||||
|
outPoint: toRemoteOutPoint,
|
||||||
|
witness: buildWitness(witnessStack, toRemoteScript),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assembleJusticeTxn accepts the breached inputs recovered from state update
|
||||||
|
// and attempts to construct the justice transaction that sweeps the victims
|
||||||
|
// funds to their wallet and claims the watchtower's reward.
|
||||||
|
func (p *JusticeDescriptor) assembleJusticeTxn(txWeight int64,
|
||||||
|
inputs ...*breachedInput) (*wire.MsgTx, error) {
|
||||||
|
|
||||||
|
justiceTxn := wire.NewMsgTx(2)
|
||||||
|
|
||||||
|
// First, construct add the breached inputs to our justice transaction
|
||||||
|
// and compute the total amount that will be swept.
|
||||||
|
var totalAmt btcutil.Amount
|
||||||
|
for _, input := range inputs {
|
||||||
|
totalAmt += btcutil.Amount(input.txOut.Value)
|
||||||
|
justiceTxn.AddTxIn(&wire.TxIn{
|
||||||
|
PreviousOutPoint: input.outPoint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using the total input amount and the transaction's weight, compute
|
||||||
|
// the sweep and reward amounts. This corresponds to the amount returned
|
||||||
|
// to the victim and the amount paid to the tower, respectively. To do
|
||||||
|
// so, the required transaction fee is subtracted from the total, and
|
||||||
|
// the remaining amount is divided according to the prenegotiated reward
|
||||||
|
// rate from the client's session info.
|
||||||
|
sweepAmt, rewardAmt, err := p.SessionInfo.ComputeSweepOutputs(
|
||||||
|
totalAmt, txWeight,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(conner): abort/don't add if outputs are dusty
|
||||||
|
|
||||||
|
// Add the sweep and reward outputs to the justice transaction.
|
||||||
|
justiceTxn.AddTxOut(&wire.TxOut{
|
||||||
|
PkScript: p.JusticeKit.SweepAddress[:],
|
||||||
|
Value: int64(sweepAmt),
|
||||||
|
})
|
||||||
|
justiceTxn.AddTxOut(&wire.TxOut{
|
||||||
|
PkScript: p.SessionInfo.RewardAddress,
|
||||||
|
Value: int64(rewardAmt),
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO(conner): apply and handle BIP69 sort
|
||||||
|
|
||||||
|
btx := btcutil.NewTx(justiceTxn)
|
||||||
|
if err := blockchain.CheckTransactionSanity(btx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach each of the provided witnesses to the transaction.
|
||||||
|
for i, input := range inputs {
|
||||||
|
justiceTxn.TxIn[i].Witness = input.witness
|
||||||
|
|
||||||
|
// Validate the reconstructed witnesses to ensure they are valid
|
||||||
|
// for the breached inputs.
|
||||||
|
vm, err := txscript.NewEngine(
|
||||||
|
input.txOut.PkScript, justiceTxn, i,
|
||||||
|
txscript.StandardVerifyFlags,
|
||||||
|
nil, nil, input.txOut.Value,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := vm.Execute(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return justiceTxn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJusticeTxn computes the justice transaction that sweeps a breaching
|
||||||
|
// commitment transaction. The justice transaction is constructed by assembling
|
||||||
|
// the witnesses using data provided by the client in a prior state update.
|
||||||
|
func (p *JusticeDescriptor) CreateJusticeTxn() (*wire.MsgTx, error) {
|
||||||
|
var (
|
||||||
|
sweepInputs = make([]*breachedInput, 0, 2)
|
||||||
|
weightEstimate lnwallet.TxWeightEstimator
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add our reward address to the weight estimate.
|
||||||
|
weightEstimate.AddP2WKHOutput()
|
||||||
|
|
||||||
|
// Add the sweep address's contribution, depending on whether it is a
|
||||||
|
// p2wkh or p2wsh output.
|
||||||
|
switch len(p.JusticeKit.SweepAddress) {
|
||||||
|
case lnwallet.P2WPKHSize:
|
||||||
|
weightEstimate.AddP2WKHOutput()
|
||||||
|
|
||||||
|
case lnwallet.P2WSHSize:
|
||||||
|
weightEstimate.AddP2WSHOutput()
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, ErrUnknownSweepAddrType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble the breached to-local output from the justice descriptor and
|
||||||
|
// add it to our weight estimate.
|
||||||
|
toLocalInput, err := p.commitToLocalInput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
weightEstimate.AddWitnessInput(lnwallet.ToLocalPenaltyWitnessSize)
|
||||||
|
sweepInputs = append(sweepInputs, toLocalInput)
|
||||||
|
|
||||||
|
// If the justice kit specifies that we have to sweep the to-remote
|
||||||
|
// output, we'll also try to assemble the output and add it to weight
|
||||||
|
// estimate if successful.
|
||||||
|
if p.JusticeKit.HasCommitToRemoteOutput() {
|
||||||
|
toRemoteInput, err := p.commitToRemoteInput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
weightEstimate.AddWitnessInput(lnwallet.P2WKHWitnessSize)
|
||||||
|
sweepInputs = append(sweepInputs, toRemoteInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(conner): sweep htlc outputs
|
||||||
|
|
||||||
|
txWeight := int64(weightEstimate.Weight())
|
||||||
|
|
||||||
|
return p.assembleJusticeTxn(txWeight, sweepInputs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findTxOutByPkScript searches the given transaction for an output whose
|
||||||
|
// pkscript matches the query. If one is found, the TxOut is returned along with
|
||||||
|
// the index.
|
||||||
|
//
|
||||||
|
// NOTE: The search stops after the first match is found.
|
||||||
|
func findTxOutByPkScript(txn *wire.MsgTx,
|
||||||
|
pkScript []byte) (uint32, *wire.TxOut, error) {
|
||||||
|
|
||||||
|
found, index := lnwallet.FindScriptOutputIndex(txn, pkScript)
|
||||||
|
if !found {
|
||||||
|
return 0, nil, ErrOutputNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return index, txn.TxOut[index], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWitness appends the witness script to a given witness stack.
|
||||||
|
func buildWitness(witnessStack [][]byte, witnessScript []byte) [][]byte {
|
||||||
|
witness := make([][]byte, len(witnessStack)+1)
|
||||||
|
lastIdx := copy(witness, witnessStack)
|
||||||
|
witness[lastIdx] = witnessScript
|
||||||
|
|
||||||
|
return witness
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// +build dev
|
||||||
|
|
||||||
|
package lookout_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/blockchain"
|
||||||
|
"github.com/btcsuite/btcd/btcec"
|
||||||
|
"github.com/btcsuite/btcd/txscript"
|
||||||
|
"github.com/btcsuite/btcd/wire"
|
||||||
|
"github.com/btcsuite/btcutil"
|
||||||
|
"github.com/davecgh/go-spew/spew"
|
||||||
|
"github.com/lightningnetwork/lnd/keychain"
|
||||||
|
"github.com/lightningnetwork/lnd/lnwallet"
|
||||||
|
"github.com/lightningnetwork/lnd/lnwire"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/blob"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/lookout"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/wtdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const csvDelay uint32 = 144
|
||||||
|
|
||||||
|
var (
|
||||||
|
revPrivBytes = []byte{
|
||||||
|
0x8f, 0x4b, 0x51, 0x83, 0xa9, 0x34, 0xbd, 0x5f,
|
||||||
|
0x74, 0x6c, 0x9d, 0x5c, 0xae, 0x88, 0x2d, 0x31,
|
||||||
|
0x06, 0x90, 0xdd, 0x8c, 0x9b, 0x31, 0xbc, 0xd1,
|
||||||
|
0x78, 0x91, 0x88, 0x2a, 0xf9, 0x74, 0xa0, 0xef,
|
||||||
|
}
|
||||||
|
|
||||||
|
toLocalPrivBytes = []byte{
|
||||||
|
0xde, 0x17, 0xc1, 0x2f, 0xdc, 0x1b, 0xc0, 0xc6,
|
||||||
|
0x59, 0x5d, 0xf9, 0xc1, 0x3e, 0x89, 0xbc, 0x6f,
|
||||||
|
0x01, 0x85, 0x45, 0x76, 0x26, 0xce, 0x9c, 0x55,
|
||||||
|
0x3b, 0xc9, 0xec, 0x3d, 0xd8, 0x8b, 0xac, 0xa8,
|
||||||
|
}
|
||||||
|
|
||||||
|
toRemotePrivBytes = []byte{
|
||||||
|
0x28, 0x59, 0x6f, 0x36, 0xb8, 0x9f, 0x19, 0x5d,
|
||||||
|
0xcb, 0x07, 0x48, 0x8a, 0xe5, 0x89, 0x71, 0x74,
|
||||||
|
0x70, 0x4c, 0xff, 0x1e, 0x9c, 0x00, 0x93, 0xbe,
|
||||||
|
0xe2, 0x2e, 0x68, 0x08, 0x4c, 0xb4, 0x0f, 0x4f,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockSigner struct {
|
||||||
|
index uint32
|
||||||
|
keys map[keychain.KeyLocator]*btcec.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockSigner() *mockSigner {
|
||||||
|
return &mockSigner{
|
||||||
|
keys: make(map[keychain.KeyLocator]*btcec.PrivateKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mockSigner) SignOutputRaw(tx *wire.MsgTx,
|
||||||
|
signDesc *lnwallet.SignDescriptor) ([]byte, error) {
|
||||||
|
|
||||||
|
witnessScript := signDesc.WitnessScript
|
||||||
|
amt := signDesc.Output.Value
|
||||||
|
|
||||||
|
privKey, ok := s.keys[signDesc.KeyDesc.KeyLocator]
|
||||||
|
if !ok {
|
||||||
|
panic("cannot sign w/ unknown key")
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := txscript.RawTxInWitnessSignature(
|
||||||
|
tx, signDesc.SigHashes, signDesc.InputIndex, amt,
|
||||||
|
witnessScript, signDesc.HashType, privKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sig[:len(sig)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mockSigner) ComputeInputScript(tx *wire.MsgTx,
|
||||||
|
signDesc *lnwallet.SignDescriptor) (*lnwallet.InputScript, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mockSigner) addPrivKey(privKey *btcec.PrivateKey) keychain.KeyLocator {
|
||||||
|
keyLoc := keychain.KeyLocator{
|
||||||
|
Index: s.index,
|
||||||
|
}
|
||||||
|
s.index++
|
||||||
|
|
||||||
|
s.keys[keyLoc] = privKey
|
||||||
|
|
||||||
|
return keyLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJusticeDescriptor(t *testing.T) {
|
||||||
|
const (
|
||||||
|
localAmount = btcutil.Amount(100000)
|
||||||
|
remoteAmount = btcutil.Amount(200000)
|
||||||
|
totalAmount = localAmount + remoteAmount
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse the key pairs for all keys used in the test.
|
||||||
|
revSK, revPK := btcec.PrivKeyFromBytes(
|
||||||
|
btcec.S256(), revPrivBytes,
|
||||||
|
)
|
||||||
|
_, toLocalPK := btcec.PrivKeyFromBytes(
|
||||||
|
btcec.S256(), toLocalPrivBytes,
|
||||||
|
)
|
||||||
|
toRemoteSK, toRemotePK := btcec.PrivKeyFromBytes(
|
||||||
|
btcec.S256(), toRemotePrivBytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the signer, and add the revocation and to-remote privkeys.
|
||||||
|
signer := newMockSigner()
|
||||||
|
var (
|
||||||
|
revKeyLoc = signer.addPrivKey(revSK)
|
||||||
|
toRemoteKeyLoc = signer.addPrivKey(toRemoteSK)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Construct the to-local witness script.
|
||||||
|
toLocalScript, err := lnwallet.CommitScriptToSelf(
|
||||||
|
csvDelay, toLocalPK, revPK,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to create to-local script: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the to-local witness script hash.
|
||||||
|
toLocalScriptHash, err := lnwallet.WitnessScriptHash(toLocalScript)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to create to-local witness script hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the to-remote witness script hash.
|
||||||
|
toRemoteScriptHash, err := lnwallet.CommitScriptUnencumbered(toRemotePK)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to create to-remote script: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the breaching commitment txn, containing the to-local and
|
||||||
|
// to-remote outputs. We don't need any inputs for this test.
|
||||||
|
breachTxn := &wire.MsgTx{
|
||||||
|
Version: 2,
|
||||||
|
TxIn: []*wire.TxIn{},
|
||||||
|
TxOut: []*wire.TxOut{
|
||||||
|
{
|
||||||
|
Value: int64(localAmount),
|
||||||
|
PkScript: toLocalScriptHash,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: int64(remoteAmount),
|
||||||
|
PkScript: toRemoteScriptHash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
breachTxID := breachTxn.TxHash()
|
||||||
|
|
||||||
|
// Compute the weight estimate for our justice transaction.
|
||||||
|
var weightEstimate lnwallet.TxWeightEstimator
|
||||||
|
weightEstimate.AddP2WKHOutput()
|
||||||
|
weightEstimate.AddP2WKHOutput()
|
||||||
|
weightEstimate.AddWitnessInput(lnwallet.ToLocalPenaltyWitnessSize)
|
||||||
|
weightEstimate.AddWitnessInput(lnwallet.P2WKHWitnessSize)
|
||||||
|
txWeight := weightEstimate.Weight()
|
||||||
|
|
||||||
|
// Create a session info so that simulate agreement of the sweep
|
||||||
|
// parameters that should be used in constructing the justice
|
||||||
|
// transaction.
|
||||||
|
sessionInfo := &wtdb.SessionInfo{
|
||||||
|
SweepFeeRate: 2000,
|
||||||
|
RewardRate: 900000,
|
||||||
|
RewardAddress: makeAddrSlice(22),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given the total input amount and the weight estimate, compute the
|
||||||
|
// amount that should be swept for the victim and the amount taken as a
|
||||||
|
// reward by the watchtower.
|
||||||
|
sweepAmt, rewardAmt, err := sessionInfo.ComputeSweepOutputs(
|
||||||
|
totalAmount, int64(txWeight),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to compute sweep outputs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin to assemble the justice kit, starting with the sweep address,
|
||||||
|
// pubkeys, and csv delay.
|
||||||
|
justiceKit := &blob.JusticeKit{
|
||||||
|
SweepAddress: makeAddrSlice(22),
|
||||||
|
CSVDelay: csvDelay,
|
||||||
|
}
|
||||||
|
copy(justiceKit.RevocationPubKey[:], revPK.SerializeCompressed())
|
||||||
|
copy(justiceKit.LocalDelayPubKey[:], toLocalPK.SerializeCompressed())
|
||||||
|
copy(justiceKit.CommitToRemotePubKey[:], toRemotePK.SerializeCompressed())
|
||||||
|
|
||||||
|
// Create a transaction spending from the outputs of the breach
|
||||||
|
// transaction created earlier. The inputs are always ordered w/
|
||||||
|
// to-local and then to-remote. The outputs are always added as the
|
||||||
|
// sweep address then reward address.
|
||||||
|
justiceTxn := &wire.MsgTx{
|
||||||
|
Version: 2,
|
||||||
|
TxIn: []*wire.TxIn{
|
||||||
|
{
|
||||||
|
PreviousOutPoint: wire.OutPoint{
|
||||||
|
Hash: breachTxID,
|
||||||
|
Index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
PreviousOutPoint: wire.OutPoint{
|
||||||
|
Hash: breachTxID,
|
||||||
|
Index: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TxOut: []*wire.TxOut{
|
||||||
|
{
|
||||||
|
|
||||||
|
Value: int64(sweepAmt),
|
||||||
|
PkScript: justiceKit.SweepAddress,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
|
||||||
|
Value: int64(rewardAmt),
|
||||||
|
PkScript: sessionInfo.RewardAddress,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
hashCache := txscript.NewTxSigHashes(justiceTxn)
|
||||||
|
|
||||||
|
// Create the sign descriptor used to sign for the to-local input.
|
||||||
|
toLocalSignDesc := &lnwallet.SignDescriptor{
|
||||||
|
KeyDesc: keychain.KeyDescriptor{
|
||||||
|
KeyLocator: revKeyLoc,
|
||||||
|
},
|
||||||
|
WitnessScript: toLocalScript,
|
||||||
|
Output: breachTxn.TxOut[0],
|
||||||
|
SigHashes: hashCache,
|
||||||
|
InputIndex: 0,
|
||||||
|
HashType: txscript.SigHashAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the sign descriptor used to sign for the to-remote input.
|
||||||
|
toRemoteSignDesc := &lnwallet.SignDescriptor{
|
||||||
|
KeyDesc: keychain.KeyDescriptor{
|
||||||
|
KeyLocator: toRemoteKeyLoc,
|
||||||
|
PubKey: toRemotePK,
|
||||||
|
},
|
||||||
|
WitnessScript: toRemoteScriptHash,
|
||||||
|
Output: breachTxn.TxOut[1],
|
||||||
|
SigHashes: hashCache,
|
||||||
|
InputIndex: 1,
|
||||||
|
HashType: txscript.SigHashAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that our test justice transaction is sane.
|
||||||
|
btx := btcutil.NewTx(justiceTxn)
|
||||||
|
if err := blockchain.CheckTransactionSanity(btx); err != nil {
|
||||||
|
t.Fatalf("justice txn is not sane: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute a DER-encoded signature for the to-local input.
|
||||||
|
toLocalSigRaw, err := signer.SignOutputRaw(justiceTxn, toLocalSignDesc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to sign to-local input: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the witness for the to-remote input. The first element is a
|
||||||
|
// DER-encoded signature under the to-remote pubkey. The sighash flag is
|
||||||
|
// also present, so we trim it.
|
||||||
|
toRemoteWitness, err := lnwallet.CommitSpendNoDelay(
|
||||||
|
signer, toRemoteSignDesc, justiceTxn,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to sign to-remote input: %v", err)
|
||||||
|
}
|
||||||
|
toRemoteSigRaw := toRemoteWitness[0][:len(toRemoteWitness[0])-1]
|
||||||
|
|
||||||
|
// Convert the DER to-local sig into a fixed-size signature.
|
||||||
|
toLocalSig, err := lnwire.NewSigFromRawSignature(toLocalSigRaw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to parse to-local signature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the DER to-remote sig into a fixed-size signature.
|
||||||
|
toRemoteSig, err := lnwire.NewSigFromRawSignature(toRemoteSigRaw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to parse to-remote signature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete our justice kit by copying the signatures into the payload.
|
||||||
|
copy(justiceKit.CommitToLocalSig[:], toLocalSig[:])
|
||||||
|
copy(justiceKit.CommitToRemoteSig[:], toRemoteSig[:])
|
||||||
|
|
||||||
|
justiceDesc := &lookout.JusticeDescriptor{
|
||||||
|
BreachedCommitTx: breachTxn,
|
||||||
|
SessionInfo: sessionInfo,
|
||||||
|
JusticeKit: justiceKit,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a breach punisher that will feed published transactions
|
||||||
|
// over the buffered channel.
|
||||||
|
publications := make(chan *wire.MsgTx, 1)
|
||||||
|
punisher := lookout.NewBreachPunisher(&lookout.PunisherConfig{
|
||||||
|
PublishTx: func(tx *wire.MsgTx) error {
|
||||||
|
publications <- tx
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Exact retribution on the offender. If no error is returned, we expect
|
||||||
|
// the justice transaction to be published via the channel.
|
||||||
|
err = punisher.Punish(justiceDesc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to punish breach: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the published justice transaction.
|
||||||
|
var wtJusticeTxn *wire.MsgTx
|
||||||
|
select {
|
||||||
|
case wtJusticeTxn = <-publications:
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
t.Fatalf("punisher did not publish justice txn")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the test's to-local witness.
|
||||||
|
justiceTxn.TxIn[0].Witness = make([][]byte, 3)
|
||||||
|
justiceTxn.TxIn[0].Witness[0] = append(toLocalSigRaw,
|
||||||
|
byte(txscript.SigHashAll))
|
||||||
|
justiceTxn.TxIn[0].Witness[1] = []byte{1}
|
||||||
|
justiceTxn.TxIn[0].Witness[2] = toLocalScript
|
||||||
|
|
||||||
|
// Construct the test's to-remote witness.
|
||||||
|
justiceTxn.TxIn[1].Witness = make([][]byte, 2)
|
||||||
|
justiceTxn.TxIn[1].Witness[0] = append(toRemoteSigRaw,
|
||||||
|
byte(txscript.SigHashAll))
|
||||||
|
justiceTxn.TxIn[1].Witness[1] = toRemotePK.SerializeCompressed()
|
||||||
|
|
||||||
|
// Assert that the watchtower derives the same justice txn.
|
||||||
|
if !reflect.DeepEqual(justiceTxn, wtJusticeTxn) {
|
||||||
|
t.Fatalf("expected justice txn: %v\ngot %v",
|
||||||
|
spew.Sdump(justiceTxn),
|
||||||
|
spew.Sdump(wtJusticeTxn))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package lookout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/btcsuite/btclog"
|
||||||
|
"github.com/lightningnetwork/lnd/build"
|
||||||
|
)
|
||||||
|
|
||||||
|
// log is a logger that is initialized with no output filters. This
|
||||||
|
// means the package will not perform any logging by default until the caller
|
||||||
|
// requests it.
|
||||||
|
var log btclog.Logger
|
||||||
|
|
||||||
|
// The default amount of logging is none.
|
||||||
|
func init() {
|
||||||
|
UseLogger(build.NewSubLogger("WTWR", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableLog disables all library log output. Logging output is disabled
|
||||||
|
// by default until UseLogger is called.
|
||||||
|
func DisableLog() {
|
||||||
|
UseLogger(btclog.Disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseLogger uses a specified Logger to output package logging info.
|
||||||
|
// This should be used in preference to SetLogWriter if the caller is also
|
||||||
|
// using btclog.
|
||||||
|
func UseLogger(logger btclog.Logger) {
|
||||||
|
log = logger
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package lookout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/wire"
|
||||||
|
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/blob"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/wtdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config houses the Lookout's required resources to properly fulfill it's duty,
|
||||||
|
// including block fetching, querying accepted state updates, and construction
|
||||||
|
// and publication of justice transactions.
|
||||||
|
type Config struct {
|
||||||
|
// DB provides persistent access to the watchtower's accepted state
|
||||||
|
// updates such that they can be queried as new blocks arrive from the
|
||||||
|
// network.
|
||||||
|
DB DB
|
||||||
|
|
||||||
|
// EpochRegistrar supports the ability to register for events corresponding to
|
||||||
|
// newly created blocks.
|
||||||
|
EpochRegistrar EpochRegistrar
|
||||||
|
|
||||||
|
// BlockFetcher supports the ability to fetch blocks from the backend or
|
||||||
|
// network.
|
||||||
|
BlockFetcher BlockFetcher
|
||||||
|
|
||||||
|
// Punisher handles the responsibility of crafting and broadcasting
|
||||||
|
// justice transaction for any breached transactions.
|
||||||
|
Punisher Punisher
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookout will check any incoming blocks against the transactions found in the
|
||||||
|
// database, and in case of matches send the information needed to create a
|
||||||
|
// penalty transaction to the punisher.
|
||||||
|
type Lookout struct {
|
||||||
|
started int32 // atomic
|
||||||
|
shutdown int32 // atomic
|
||||||
|
|
||||||
|
cfg *Config
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
quit chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a new Lookout from the given LookoutConfig.
|
||||||
|
func New(cfg *Config) *Lookout {
|
||||||
|
return &Lookout{
|
||||||
|
cfg: cfg,
|
||||||
|
quit: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start safely spins up the Lookout and begins monitoring for breaches.
|
||||||
|
func (l *Lookout) Start() error {
|
||||||
|
if !atomic.CompareAndSwapInt32(&l.started, 0, 1) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("Starting lookout")
|
||||||
|
|
||||||
|
startEpoch, err := l.cfg.DB.GetLookoutTip()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if startEpoch == nil {
|
||||||
|
log.Infof("Starting lookout from chain tip")
|
||||||
|
} else {
|
||||||
|
log.Infof("Starting lookout from epoch(height=%d hash=%x)",
|
||||||
|
startEpoch.Height, startEpoch.Hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := l.cfg.EpochRegistrar.RegisterBlockEpochNtfn(startEpoch)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to register for block epochs: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
l.wg.Add(1)
|
||||||
|
go l.watchBlocks(events)
|
||||||
|
|
||||||
|
log.Infof("Lookout started successfully")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop safely shuts down the Lookout.
|
||||||
|
func (l *Lookout) Stop() error {
|
||||||
|
if !atomic.CompareAndSwapInt32(&l.shutdown, 0, 1) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("Stopping lookout")
|
||||||
|
|
||||||
|
close(l.quit)
|
||||||
|
l.wg.Wait()
|
||||||
|
|
||||||
|
log.Infof("Lookout stopped successfully")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchBlocks serially pulls incoming epochs from the epoch source and searches
|
||||||
|
// our accepted state updates for any breached transactions. If any are found,
|
||||||
|
// we will attempt to decrypt the state updates' encrypted blobs and exact
|
||||||
|
// justice for the victim.
|
||||||
|
//
|
||||||
|
// This method MUST be run as a goroutine.
|
||||||
|
func (l *Lookout) watchBlocks(epochs *chainntnfs.BlockEpochEvent) {
|
||||||
|
defer l.wg.Done()
|
||||||
|
defer epochs.Cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case epoch := <-epochs.Epochs:
|
||||||
|
log.Debugf("Fetching block for (height=%d, hash=%x)",
|
||||||
|
epoch.Height, epoch.Hash)
|
||||||
|
|
||||||
|
// Fetch the full block from the backend corresponding
|
||||||
|
// to the newly arriving epoch.
|
||||||
|
block, err := l.cfg.BlockFetcher.GetBlock(epoch.Hash)
|
||||||
|
if err != nil {
|
||||||
|
// TODO(conner): add retry logic?
|
||||||
|
log.Errorf("Unable to fetch block for "+
|
||||||
|
"(height=%x, hash=%x): %v",
|
||||||
|
epoch.Height, epoch.Hash, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the block to see if it contains any breaches
|
||||||
|
// that we are monitoring on behalf of our clients.
|
||||||
|
err = l.processEpoch(epoch, block)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to process %s: %v",
|
||||||
|
epoch, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-l.quit:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processEpoch accepts an Epoch and queries the database for any matching state
|
||||||
|
// updates for the confirmed transactions. If any are found, the lookout
|
||||||
|
// responds by attempting to decrypt the encrypted blob and publishing the
|
||||||
|
// justice transaction.
|
||||||
|
func (l *Lookout) processEpoch(epoch *chainntnfs.BlockEpoch,
|
||||||
|
block *wire.MsgBlock) error {
|
||||||
|
|
||||||
|
numTxnsInBlock := len(block.Transactions)
|
||||||
|
|
||||||
|
log.Debugf("Scanning %d transaction in block (height=%d, hash=%x) "+
|
||||||
|
"for breaches", numTxnsInBlock, epoch.Height, epoch.Hash)
|
||||||
|
|
||||||
|
// Iterate over the transactions contained in the block, deriving a
|
||||||
|
// breach hint for each transaction and constructing an index mapping
|
||||||
|
// the hint back to it's original transaction.
|
||||||
|
hintToTx := make(map[wtdb.BreachHint]*wire.MsgTx, numTxnsInBlock)
|
||||||
|
txHints := make([]wtdb.BreachHint, 0, numTxnsInBlock)
|
||||||
|
for _, tx := range block.Transactions {
|
||||||
|
hash := tx.TxHash()
|
||||||
|
hint := wtdb.NewBreachHintFromHash(&hash)
|
||||||
|
|
||||||
|
txHints = append(txHints, hint)
|
||||||
|
hintToTx[hint] = tx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query the database to see if any of the breach hints cause a match
|
||||||
|
// with any of our accepted state updates.
|
||||||
|
matches, err := l.cfg.DB.QueryMatches(txHints)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matches were found, we are done.
|
||||||
|
if len(matches) == 0 {
|
||||||
|
log.Debugf("No breaches found in (height=%d, hash=%x)",
|
||||||
|
epoch.Height, epoch.Hash)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
breachCountStr := "breach"
|
||||||
|
if len(matches) > 1 {
|
||||||
|
breachCountStr = "breaches"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("Found %d %s in (height=%d, hash=%x)",
|
||||||
|
len(matches), breachCountStr, epoch.Height, epoch.Hash)
|
||||||
|
|
||||||
|
// For each match, use our index to retrieve the original transaction,
|
||||||
|
// which corresponds to the breaching commitment transaction. If the
|
||||||
|
// decryption succeeds, we will accumlate the assembled justice
|
||||||
|
// descriptors in a single slice
|
||||||
|
var successes []*JusticeDescriptor
|
||||||
|
for _, match := range matches {
|
||||||
|
commitTx := hintToTx[match.Hint]
|
||||||
|
log.Infof("Dispatching punisher for client %s, breach-txid=%s",
|
||||||
|
match.ID, commitTx.TxHash().String())
|
||||||
|
|
||||||
|
// The decryption key for the state update should be the full
|
||||||
|
// txid of the breaching commitment transaction.
|
||||||
|
commitTxID := commitTx.TxHash()
|
||||||
|
|
||||||
|
// Now, decrypt the blob of justice that we received in the
|
||||||
|
// state update. This will contain all information required to
|
||||||
|
// sweep the breached commitment outputs.
|
||||||
|
justiceKit, err := blob.Decrypt(
|
||||||
|
commitTxID[:], match.EncryptedBlob,
|
||||||
|
match.SessionInfo.Version,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// If the decryption fails, this implies either that the
|
||||||
|
// client sent an invalid blob, or that the breach hint
|
||||||
|
// caused a match on the txid, but this isn't actually
|
||||||
|
// the right transaction.
|
||||||
|
log.Debugf("Unable to decrypt blob for client %s, "+
|
||||||
|
"breach-txid %s: %v", match.ID,
|
||||||
|
commitTx.TxHash().String(), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
justiceDesc := &JusticeDescriptor{
|
||||||
|
BreachedCommitTx: commitTx,
|
||||||
|
SessionInfo: match.SessionInfo,
|
||||||
|
JusticeKit: justiceKit,
|
||||||
|
}
|
||||||
|
successes = append(successes, justiceDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(conner): mark successfully decrypted blob so that we can
|
||||||
|
// reliably rebroadcast on startup
|
||||||
|
|
||||||
|
// Now, we'll dispatch a punishment for each successful match in
|
||||||
|
// parallel. This will assemble the justice transaction for each and
|
||||||
|
// watch for their confirmation on chain.
|
||||||
|
for _, justiceDesc := range successes {
|
||||||
|
l.wg.Add(1)
|
||||||
|
go l.dispatchPunisher(justiceDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.cfg.DB.SetLookoutTip(epoch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchPunisher accepts a justice descriptor corresponding to a successfully
|
||||||
|
// decrypted blob. The punisher will then construct the witness scripts and
|
||||||
|
// witness stacks for the breached outputs. If construction of the justice
|
||||||
|
// transaction is successful, it will be published to the network to retrieve
|
||||||
|
// the funds and claim the watchtower's reward.
|
||||||
|
//
|
||||||
|
// This method MUST be run as a goroutine.
|
||||||
|
func (l *Lookout) dispatchPunisher(desc *JusticeDescriptor) {
|
||||||
|
defer l.wg.Done()
|
||||||
|
|
||||||
|
// Give the justice descriptor to the punisher to construct and publish
|
||||||
|
// the justice transaction. The lookout's quit channel is provided so
|
||||||
|
// that long-running tasks that watch for on-chain events can be
|
||||||
|
// canceled during shutdown since this method is waitgrouped.
|
||||||
|
err := l.cfg.Punisher.Punish(desc, l.quit)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to punish breach-txid %s for %x: %v",
|
||||||
|
desc.SessionInfo.ID,
|
||||||
|
desc.BreachedCommitTx.TxHash().String(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("Punishment for client %s with breach-txid=%s dispatched",
|
||||||
|
desc.SessionInfo.ID, desc.BreachedCommitTx.TxHash().String())
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
// +build dev
|
||||||
|
|
||||||
|
package lookout_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/wire"
|
||||||
|
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/blob"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/lookout"
|
||||||
|
"github.com/lightningnetwork/lnd/watchtower/wtdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockPunisher struct {
|
||||||
|
matches chan *lookout.JusticeDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *mockPunisher) Punish(
|
||||||
|
info *lookout.JusticeDescriptor, quit <-chan struct{}) error {
|
||||||
|
|
||||||
|
p.matches <- info
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeArray32(i uint64) [32]byte {
|
||||||
|
var arr [32]byte
|
||||||
|
binary.BigEndian.PutUint64(arr[:], i)
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeArray33(i uint64) [33]byte {
|
||||||
|
var arr [33]byte
|
||||||
|
binary.BigEndian.PutUint64(arr[:], i)
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePubKey(i uint64) [33]byte {
|
||||||
|
var arr [33]byte
|
||||||
|
arr[0] = 0x02
|
||||||
|
if i%2 == 1 {
|
||||||
|
arr[0] |= 0x01
|
||||||
|
}
|
||||||
|
binary.BigEndian.PutUint64(arr[1:], i)
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeArray64(i uint64) [64]byte {
|
||||||
|
var arr [64]byte
|
||||||
|
binary.BigEndian.PutUint64(arr[:], i)
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAddrSlice(size int) []byte {
|
||||||
|
addr := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, addr); err != nil {
|
||||||
|
panic("cannot make addr")
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookoutBreachMatching(t *testing.T) {
|
||||||
|
db := wtdb.NewMockDB()
|
||||||
|
|
||||||
|
// Initialize an mock backend to feed the lookout blocks.
|
||||||
|
backend := lookout.NewMockBackend()
|
||||||
|
|
||||||
|
// Initialize a punisher that will feed any successfully constructed
|
||||||
|
// justice descriptors across the matches channel.
|
||||||
|
matches := make(chan *lookout.JusticeDescriptor)
|
||||||
|
punisher := &mockPunisher{matches: matches}
|
||||||
|
|
||||||
|
// With the resources in place, initialize and start our watcher.
|
||||||
|
watcher := lookout.New(&lookout.Config{
|
||||||
|
BlockFetcher: backend,
|
||||||
|
DB: db,
|
||||||
|
EpochRegistrar: backend,
|
||||||
|
Punisher: punisher,
|
||||||
|
})
|
||||||
|
if err := watcher.Start(); err != nil {
|
||||||
|
t.Fatalf("unable to start watcher: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create two sessions, representing two distinct clients.
|
||||||
|
sessionInfo1 := &wtdb.SessionInfo{
|
||||||
|
ID: makeArray33(1),
|
||||||
|
MaxUpdates: 10,
|
||||||
|
RewardAddress: makeAddrSlice(22),
|
||||||
|
}
|
||||||
|
sessionInfo2 := &wtdb.SessionInfo{
|
||||||
|
ID: makeArray33(2),
|
||||||
|
MaxUpdates: 10,
|
||||||
|
RewardAddress: makeAddrSlice(22),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert both sessions into the watchtower's database.
|
||||||
|
err := db.InsertSessionInfo(sessionInfo1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to insert session info: %v", err)
|
||||||
|
}
|
||||||
|
err = db.InsertSessionInfo(sessionInfo2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to insert session info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct two distinct transactions, that will be used to test the
|
||||||
|
// breach hint matching.
|
||||||
|
tx := wire.NewMsgTx(wire.TxVersion)
|
||||||
|
hash1 := tx.TxHash()
|
||||||
|
|
||||||
|
tx2 := wire.NewMsgTx(wire.TxVersion + 1)
|
||||||
|
hash2 := tx2.TxHash()
|
||||||
|
|
||||||
|
if bytes.Equal(hash1[:], hash2[:]) {
|
||||||
|
t.Fatalf("breach txids should be different")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a justice kit for each possible breach transaction.
|
||||||
|
blob1 := &blob.JusticeKit{
|
||||||
|
SweepAddress: makeAddrSlice(22),
|
||||||
|
RevocationPubKey: makePubKey(1),
|
||||||
|
LocalDelayPubKey: makePubKey(1),
|
||||||
|
CSVDelay: 144,
|
||||||
|
CommitToLocalSig: makeArray64(1),
|
||||||
|
}
|
||||||
|
blob2 := &blob.JusticeKit{
|
||||||
|
SweepAddress: makeAddrSlice(22),
|
||||||
|
RevocationPubKey: makePubKey(2),
|
||||||
|
LocalDelayPubKey: makePubKey(2),
|
||||||
|
CSVDelay: 144,
|
||||||
|
CommitToLocalSig: makeArray64(2),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the first justice kit under the txid of the first txn.
|
||||||
|
encBlob1, err := blob1.Encrypt(hash1[:], 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to encrypt sweep detail 1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the second justice kit under the txid of the second txn.
|
||||||
|
encBlob2, err := blob2.Encrypt(hash2[:], 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to encrypt sweep detail 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add both state updates to the tower's database.
|
||||||
|
txBlob1 := &wtdb.SessionStateUpdate{
|
||||||
|
ID: makeArray33(1),
|
||||||
|
Hint: wtdb.NewBreachHintFromHash(&hash1),
|
||||||
|
EncryptedBlob: encBlob1,
|
||||||
|
SeqNum: 1,
|
||||||
|
}
|
||||||
|
txBlob2 := &wtdb.SessionStateUpdate{
|
||||||
|
ID: makeArray33(2),
|
||||||
|
Hint: wtdb.NewBreachHintFromHash(&hash2),
|
||||||
|
EncryptedBlob: encBlob2,
|
||||||
|
SeqNum: 1,
|
||||||
|
}
|
||||||
|
if _, err := db.InsertStateUpdate(txBlob1); err != nil {
|
||||||
|
t.Fatalf("unable to add tx to db: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.InsertStateUpdate(txBlob2); err != nil {
|
||||||
|
t.Fatalf("unable to add tx to db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a block containing the first transaction, connecting this
|
||||||
|
// block should match the first state update's breach hint.
|
||||||
|
block := &wire.MsgBlock{
|
||||||
|
Header: wire.BlockHeader{
|
||||||
|
Nonce: 1,
|
||||||
|
},
|
||||||
|
Transactions: []*wire.MsgTx{tx},
|
||||||
|
}
|
||||||
|
blockHash := block.BlockHash()
|
||||||
|
epoch := &chainntnfs.BlockEpoch{
|
||||||
|
Hash: &blockHash,
|
||||||
|
Height: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect the block via our mock backend.
|
||||||
|
backend.ConnectEpoch(epoch, block)
|
||||||
|
|
||||||
|
// This should trigger dispatch of the justice kit for the first tx.
|
||||||
|
select {
|
||||||
|
case match := <-matches:
|
||||||
|
txid := match.BreachedCommitTx.TxHash()
|
||||||
|
if !bytes.Equal(txid[:], hash1[:]) {
|
||||||
|
t.Fatalf("matched breach did not match tx1's txid")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatalf("breach tx1 was not matched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that at most one txn was matched as a result of connecting the
|
||||||
|
// first block.
|
||||||
|
select {
|
||||||
|
case <-matches:
|
||||||
|
t.Fatalf("only one txn should have been matched")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now, construct a second block containing the second breach
|
||||||
|
// transaction.
|
||||||
|
block2 := &wire.MsgBlock{
|
||||||
|
Header: wire.BlockHeader{
|
||||||
|
Nonce: 2,
|
||||||
|
},
|
||||||
|
Transactions: []*wire.MsgTx{tx2},
|
||||||
|
}
|
||||||
|
blockHash2 := block2.BlockHash()
|
||||||
|
epoch2 := &chainntnfs.BlockEpoch{
|
||||||
|
Hash: &blockHash2,
|
||||||
|
Height: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the block hashes do no collide, otherwise the mock
|
||||||
|
// backend may not function properly.
|
||||||
|
if bytes.Equal(blockHash[:], blockHash2[:]) {
|
||||||
|
t.Fatalf("block hashes should be different")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect the second block, such that the block is delivered via the
|
||||||
|
// epoch stream.
|
||||||
|
backend.ConnectEpoch(epoch2, block2)
|
||||||
|
|
||||||
|
// This should trigger dispatch of the justice kit for the second txn.
|
||||||
|
select {
|
||||||
|
case match := <-matches:
|
||||||
|
txid := match.BreachedCommitTx.TxHash()
|
||||||
|
if !bytes.Equal(txid[:], hash2[:]) {
|
||||||
|
t.Fatalf("received breach did not match tx2's txid")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatalf("tx was not matched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that at most one txn was matched as a result of connecting the
|
||||||
|
// second block.
|
||||||
|
select {
|
||||||
|
case <-matches:
|
||||||
|
t.Fatalf("only one txn should have been matched")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// +build dev
|
||||||
|
|
||||||
|
package lookout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||||
|
"github.com/btcsuite/btcd/wire"
|
||||||
|
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockBackend struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
blocks chan *chainntnfs.BlockEpoch
|
||||||
|
epochs map[chainhash.Hash]*wire.MsgBlock
|
||||||
|
quit chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockBackend() *MockBackend {
|
||||||
|
return &MockBackend{
|
||||||
|
blocks: make(chan *chainntnfs.BlockEpoch),
|
||||||
|
epochs: make(map[chainhash.Hash]*wire.MsgBlock),
|
||||||
|
quit: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockBackend) RegisterBlockEpochNtfn(*chainntnfs.BlockEpoch) (
|
||||||
|
*chainntnfs.BlockEpochEvent, error) {
|
||||||
|
|
||||||
|
return &chainntnfs.BlockEpochEvent{
|
||||||
|
Epochs: m.blocks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockBackend) GetBlock(hash *chainhash.Hash) (*wire.MsgBlock, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
block, ok := m.epochs[*hash]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown block for hash %x", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return block, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockBackend) ConnectEpoch(epoch *chainntnfs.BlockEpoch,
|
||||||
|
block *wire.MsgBlock) {
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.epochs[*epoch.Hash] = block
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case m.blocks <- epoch:
|
||||||
|
case <-m.quit:
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package lookout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/btcsuite/btcd/wire"
|
||||||
|
"github.com/lightningnetwork/lnd/lnwallet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PunisherConfig houses the resources required by the Punisher.
|
||||||
|
type PunisherConfig struct {
|
||||||
|
// PublishTx provides the ability to send a signed transaction to the
|
||||||
|
// network.
|
||||||
|
PublishTx func(*wire.MsgTx) error
|
||||||
|
|
||||||
|
// TODO(conner) add DB tracking and spend ntfn registration to see if
|
||||||
|
// ours confirmed or not
|
||||||
|
}
|
||||||
|
|
||||||
|
// BreachPunisher handles the responsibility of constructing and broadcasting
|
||||||
|
// justice transactions. Justice transactions are constructed from previously
|
||||||
|
// accepted state updates uploaded by the watchtower's clients.
|
||||||
|
type BreachPunisher struct {
|
||||||
|
cfg *PunisherConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBreachPunisher constructs a new BreachPunisher given a PunisherConfig.
|
||||||
|
func NewBreachPunisher(cfg *PunisherConfig) *BreachPunisher {
|
||||||
|
return &BreachPunisher{
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Punish constructs a justice transaction given a JusticeDescriptor and
|
||||||
|
// publishes is it to the network.
|
||||||
|
func (p *BreachPunisher) Punish(desc *JusticeDescriptor, quit <-chan struct{}) error {
|
||||||
|
justiceTxn, err := desc.CreateJusticeTxn()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to create justice txn for "+
|
||||||
|
"client=%s with breach-txid=%x: %v",
|
||||||
|
desc.SessionInfo.ID, desc.BreachedCommitTx.TxHash(), err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("Publishing justice transaction txid=%x for client=%s",
|
||||||
|
justiceTxn.TxHash(), desc.SessionInfo.ID)
|
||||||
|
|
||||||
|
err = p.cfg.PublishTx(justiceTxn)
|
||||||
|
if err != nil && err != lnwallet.ErrDoubleSpend {
|
||||||
|
log.Errorf("Unable to publish justice txn for client=%s",
|
||||||
|
"with breach-txid=%x: %v",
|
||||||
|
desc.SessionInfo.ID, desc.BreachedCommitTx.TxHash(), err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(conner): register for spend and remove from db after
|
||||||
|
// confirmation
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+60
-3
@@ -2,16 +2,23 @@
|
|||||||
|
|
||||||
package wtdb
|
package wtdb
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||||
|
)
|
||||||
|
|
||||||
type MockDB struct {
|
type MockDB struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sessions map[SessionID]*SessionInfo
|
lastEpoch *chainntnfs.BlockEpoch
|
||||||
|
sessions map[SessionID]*SessionInfo
|
||||||
|
blobs map[BreachHint]map[SessionID]*SessionStateUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMockDB() *MockDB {
|
func NewMockDB() *MockDB {
|
||||||
return &MockDB{
|
return &MockDB{
|
||||||
sessions: make(map[SessionID]*SessionInfo),
|
sessions: make(map[SessionID]*SessionInfo),
|
||||||
|
blobs: make(map[BreachHint]map[SessionID]*SessionStateUpdate),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +36,13 @@ func (db *MockDB) InsertStateUpdate(update *SessionStateUpdate) (uint16, error)
|
|||||||
return info.LastApplied, err
|
return info.LastApplied, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionsToUpdates, ok := db.blobs[update.Hint]
|
||||||
|
if !ok {
|
||||||
|
sessionsToUpdates = make(map[SessionID]*SessionStateUpdate)
|
||||||
|
db.blobs[update.Hint] = sessionsToUpdates
|
||||||
|
}
|
||||||
|
sessionsToUpdates[update.ID] = update
|
||||||
|
|
||||||
return info.LastApplied, nil
|
return info.LastApplied, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,3 +69,46 @@ func (db *MockDB) InsertSessionInfo(info *SessionInfo) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *MockDB) GetLookoutTip() (*chainntnfs.BlockEpoch, error) {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
return db.lastEpoch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *MockDB) QueryMatches(breachHints []BreachHint) ([]Match, error) {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var matches []Match
|
||||||
|
for _, hint := range breachHints {
|
||||||
|
sessionsToUpdates, ok := db.blobs[hint]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, update := range sessionsToUpdates {
|
||||||
|
info, ok := db.sessions[id]
|
||||||
|
if !ok {
|
||||||
|
panic("session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
match := Match{
|
||||||
|
ID: id,
|
||||||
|
SeqNum: update.SeqNum,
|
||||||
|
Hint: hint,
|
||||||
|
EncryptedBlob: update.EncryptedBlob,
|
||||||
|
SessionInfo: info,
|
||||||
|
}
|
||||||
|
matches = append(matches, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *MockDB) SetLookoutTip(epoch *chainntnfs.BlockEpoch) error {
|
||||||
|
db.lastEpoch = epoch
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package wtdb
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcutil"
|
||||||
"github.com/lightningnetwork/lnd/lnwallet"
|
"github.com/lightningnetwork/lnd/lnwallet"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +35,11 @@ var (
|
|||||||
// number larger than the session's max number of updates.
|
// number larger than the session's max number of updates.
|
||||||
ErrSessionConsumed = errors.New("all session updates have been " +
|
ErrSessionConsumed = errors.New("all session updates have been " +
|
||||||
"consumed")
|
"consumed")
|
||||||
|
|
||||||
|
// ErrFeeExceedsInputs signals that the total input value of breaching
|
||||||
|
// commitment txn is insufficient to cover the fees required to sweep
|
||||||
|
// it.
|
||||||
|
ErrFeeExceedsInputs = errors.New("sweep fee exceeds input values")
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionInfo holds the negotiated session parameters for single session id,
|
// SessionInfo holds the negotiated session parameters for single session id,
|
||||||
@@ -103,3 +109,58 @@ func (s *SessionInfo) AcceptUpdateSequence(seqNum, lastApplied uint16) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ComputeSweepOutputs splits the total funds in a breaching commitment
|
||||||
|
// transaction between the victim and the tower, according to the sweep fee rate
|
||||||
|
// and reward rate. The fees are first subtracted from the overall total, before
|
||||||
|
// splitting the remaining balance amongst the victim and tower.
|
||||||
|
func (s *SessionInfo) ComputeSweepOutputs(totalAmt btcutil.Amount,
|
||||||
|
txVSize int64) (btcutil.Amount, btcutil.Amount, error) {
|
||||||
|
|
||||||
|
txFee := s.SweepFeeRate.FeeForWeight(txVSize)
|
||||||
|
if txFee > totalAmt {
|
||||||
|
return 0, 0, ErrFeeExceedsInputs
|
||||||
|
}
|
||||||
|
|
||||||
|
totalAmt -= txFee
|
||||||
|
|
||||||
|
// Apply the reward rate to the remaining total, specified in millionths
|
||||||
|
// of the available balance.
|
||||||
|
rewardAmt := (totalAmt*btcutil.Amount(s.RewardRate) + 999999) / 1000000
|
||||||
|
sweepAmt := totalAmt - rewardAmt
|
||||||
|
|
||||||
|
// TODO(conner): check dustiness
|
||||||
|
|
||||||
|
return sweepAmt, rewardAmt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match is returned in response to a database query for a breach hints
|
||||||
|
// contained in a particular block. The match encapsulates all data required to
|
||||||
|
// properly decrypt a client's encrypted blob, and pursue action on behalf of
|
||||||
|
// the victim by reconstructing the justice transaction and broadcasting it to
|
||||||
|
// the network.
|
||||||
|
//
|
||||||
|
// NOTE: It is possible for a match to cause a false positive, since they are
|
||||||
|
// matched on a prefix of the txid. In such an event, the likely behavior is
|
||||||
|
// that the payload will fail to decrypt.
|
||||||
|
type Match struct {
|
||||||
|
// ID is the session id of the client who uploaded the state update.
|
||||||
|
ID SessionID
|
||||||
|
|
||||||
|
// SeqNum is the session sequence number occupied by the client's state
|
||||||
|
// update. Together with ID, this allows the tower to derive the
|
||||||
|
// appropriate nonce for decryption.
|
||||||
|
SeqNum uint16
|
||||||
|
|
||||||
|
// Hint is the breach hint that triggered the match.
|
||||||
|
Hint BreachHint
|
||||||
|
|
||||||
|
// EncryptedBlob is the encrypted payload containing the justice kit
|
||||||
|
// uploaded by the client.
|
||||||
|
EncryptedBlob []byte
|
||||||
|
|
||||||
|
// SessionInfo is the contract negotiated between tower and client, that
|
||||||
|
// provides input parameters such as fee rate, reward rate, and reward
|
||||||
|
// address when attempting to reconstruct the justice transaction.
|
||||||
|
SessionInfo *SessionInfo
|
||||||
|
}
|
||||||
Reference in new issue
Block a user