diff --git a/order/batch.go b/order/batch.go index 7bac18d..ef9d445 100644 --- a/order/batch.go +++ b/order/batch.go @@ -1,5 +1,18 @@ package order +import ( + "bytes" + "fmt" + "net" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/lightninglabs/agora/client/account" + "github.com/lightninglabs/agora/client/clmrpc" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + // BatchVersion is the type for the batch verification protocol. type BatchVersion uint32 @@ -15,3 +28,184 @@ const ( // will be detected during the OrderMatchPrepare call. CurrentVersion = DefaultVersion ) + +// BatchID is a 33-byte point that uniquely identifies this batch. This ID +// will be used later for account key derivation when constructing the batch +// execution transaction. +type BatchID [33]byte + +// AccountDiff represents a matching+clearing event for a trader's account. +// This diff shows the total balance delta along with a breakdown for each item +// for a trader's account. +type AccountDiff struct { + // AccountKeyRaw is the raw serialized account public key this diff + // refers to. + AccountKeyRaw [33]byte + + // AccountKey is the parsed account public key this diff refers to. + AccountKey *btcec.PublicKey + + // EndingState is the ending on-chain state of the account after the + // executed batch as the auctioneer calculated it. + EndingState clmrpc.AccountDiff_AccountState + + // EndingBalance is the ending balance for a trader's account. + EndingBalance btcutil.Amount + + // OutpointIndex is the index of the re-created account output in the + // batch transaction. This is set to -1 if no account output has been + // created because the leftover value was considered to be dust. + OutpointIndex int32 + + // Expiry is the on-chain CSV expiry for the re-created account output. + Expiry uint32 +} + +// validateEndingState validates that the ending state of an account as +// proposed by the server is correct. +func (d *AccountDiff) validateEndingState(tx *wire.MsgTx, + acct *account.Account) error { + + state := d.EndingState + wrongStateErr := fmt.Errorf( + "unexpected state %d for ending balance %d", state, + d.EndingBalance, + ) + + // Depending on the final amount of the account, we might get + // dust which is handled differently. + if d.EndingBalance < MinNoDustAccountSize { + // The ending balance of the account is too small to be spent + // by a simple transaction and not create a dust output. We + // expect the server to set the state correctly and not re- + // create an account outpoint. + if state != clmrpc.AccountDiff_OUTPUT_DUST_EXTENDED_OFFCHAIN && + state != clmrpc.AccountDiff_OUTPUT_DUST_ADDED_TO_FEES && + state != clmrpc.AccountDiff_OUTPUT_FULLY_SPENT { + + return wrongStateErr + } + if d.OutpointIndex >= 0 { + return fmt.Errorf("unexpected outpoint index for dust " + + "account") + } + } else { + // There should be enough balance left to justify a new account + // output. We should get the outpoint from the server. + if state != clmrpc.AccountDiff_OUTPUT_RECREATED { + return wrongStateErr + } + if d.OutpointIndex < 0 { + return fmt.Errorf("outpoint index invalid for non-"+ + "dust account with state %d and balance %d", + state, d.EndingBalance) + } + + // Make sure the outpoint index is correct and there is an + // output with the correct amount there. + if d.OutpointIndex >= int32(len(tx.TxOut)) { + return fmt.Errorf("outpoint index out of bounds") + } + out := tx.TxOut[d.OutpointIndex] + if btcutil.Amount(out.Value) != d.EndingBalance { + return fmt.Errorf("invalid account output amount. got "+ + "%d expected %d", out.Value, d.EndingBalance) + } + + // Final check, make sure we arrive at the same script for the + // new account output. + nextScript, err := acct.NextOutputScript() + if err != nil { + return fmt.Errorf("could not derive next account "+ + "script: %v", err) + } + if !bytes.Equal(out.PkScript, nextScript) { + return fmt.Errorf("unexpected account output script") + } + } + + return nil +} + +// Batch is all the information the auctioneer sends to each trader for them to +// validate a batch execution. +type Batch struct { + // ID is the batch's unique ID. If multiple messages come in with the + // same ID, they are to be considered to be the _same batch_ with + // updated matches. Any previous version of a batch with that ID should + // be discarded in that case. + ID BatchID + + // BatchVersion is the version of the batch verification protocol. + Version BatchVersion + + // MatchedOrders is a map between all trader's orders and the other + // orders that were matched to them in the batch. + MatchedOrders map[Nonce][]*MatchedOrder + + // AccountDiffs is the calculated difference for each trader's account + // that was involved in the batch. + AccountDiffs []*AccountDiff + + // ExecutionFee is the FeeSchedule that was used by the server to + // calculate the execution fee. + ExecutionFee FeeSchedule + + // ClearingPrice is the fixed rate the orders were cleared at. + ClearingPrice FixedRatePremium + + // BatchTX is the complete batch transaction with all non-witness data + // fully populated. + BatchTX *wire.MsgTx + + // BatchTxFeeRate is the miner fee rate in sat/kW that was chosen for + // the batch transaction. + BatchTxFeeRate chainfee.SatPerKWeight + + // FeeRebate is the rebate that was offered to the trader if another + // batch participant wanted to pay more fees for a faster confirmation. + FeeRebate btcutil.Amount +} + +// MatchedOrder is the other side to one of our matched orders. It contains all +// the information that is needed to validate the match and to start negotiating +// the channel opening with the matched trader's node. +type MatchedOrder struct { + // Order contains the details of the other order as sent by the server. + Order Order + + // MultiSigKey is a key of the node creating the order that will be used + // to craft the channel funding TX's 2-of-2 multi signature output. + MultiSigKey [33]byte + + // NodeKey is the identity public key of the node creating the order. + NodeKey [33]byte + + // NodeAddrs is the list of network addresses of the node creating the + // order. + NodeAddrs []net.Addr + + // UnitsFilled is the number of units that were matched by this order. + UnitsFilled SupplyUnit +} + +// BatchSignature is a map type that is keyed by a trader's account key and +// contains the witness stack (slice of slice of bytes) for the input that +// spends from the current account in a batch. +type BatchSignature map[[33]byte][][]byte + +// BatchVerifier is an interface that can verify a batch from the point of view +// of the trader. +type BatchVerifier interface { + // Verify makes sure the batch prepared by the server is correct and + // can be accepted by the trader. + Verify(*Batch) error +} + +// BatchSigner is an interface that can sign for a trader's account inputs in +// a batch. +type BatchSigner interface { + // Sign returns the witness stack of all account inputs in a batch that + // belong to the trader. + Sign(*Batch) (BatchSignature, error) +} diff --git a/order/batch_signer.go b/order/batch_signer.go new file mode 100644 index 0000000..64ed212 --- /dev/null +++ b/order/batch_signer.go @@ -0,0 +1,93 @@ +package order + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/agora/client/account" + "github.com/lightninglabs/agora/client/clmscript" + "github.com/lightninglabs/loop/lndclient" + "github.com/lightningnetwork/lnd/input" +) + +// batchSigner is a type that implements the BatchSigner interface and can sign +// for a trader's account inputs in a batch. +type batchSigner struct { + getAccount func(*btcec.PublicKey) (*account.Account, error) + signer lndclient.SignerClient +} + +// Sign returns the witness stack of all account inputs in a batch that +// belong to the trader. +// +// NOTE: This method is part of the BatchSigner interface. +func (s *batchSigner) Sign(batch *Batch) (BatchSignature, error) { + ourSigs := make(BatchSignature) + hashes := txscript.NewTxSigHashes(batch.BatchTX) + + // At this point we know that the accounts charged are correct. So we + // can just go through them, find the corresponding input in the batch + // TX and sign it. + for _, acctDiff := range batch.AccountDiffs { + // Get account from DB and make sure we can create the output. + acct, err := s.getAccount(acctDiff.AccountKey) + if err != nil { + return nil, fmt.Errorf("account not found: %v", err) + } + acctOut, err := acct.Output() + if err != nil { + return nil, fmt.Errorf("could not get account output: "+ + "%v", err) + } + var acctKey [33]byte + copy(acctKey[:], acct.TraderKey.PubKey.SerializeCompressed()) + + // Find the input index we are going to sign. + inputIndex := -1 + for idx, in := range batch.BatchTX.TxIn { + if in.PreviousOutPoint == acct.OutPoint { + inputIndex = idx + } + } + if inputIndex == -1 { + return nil, fmt.Errorf("account input not found") + } + + // Gather the remaining components required to sign the + // transaction and sign it. + traderKeyTweak := clmscript.TraderKeyTweak( + acct.BatchKey, acct.Secret, acct.TraderKey.PubKey, + ) + witnessScript, err := clmscript.AccountWitnessScript( + acct.Expiry, acct.TraderKey.PubKey, acct.AuctioneerKey, + acct.BatchKey, acct.Secret, + ) + if err != nil { + return nil, err + } + signDesc := &input.SignDescriptor{ + KeyDesc: *acct.TraderKey, + SingleTweak: traderKeyTweak, + WitnessScript: witnessScript, + Output: acctOut, + HashType: txscript.SigHashAll, + InputIndex: inputIndex, + SigHashes: hashes, + } + sigs, err := s.signer.SignOutputRaw( + context.Background(), batch.BatchTX, + []*input.SignDescriptor{signDesc}, + ) + if err != nil { + return nil, err + } + ourSigs[acctKey] = sigs + } + + return ourSigs, nil +} + +// A compile-time constraint to ensure batchSigner implements BatchSigner. +var _ BatchSigner = (*batchSigner)(nil) diff --git a/order/batch_verifier.go b/order/batch_verifier.go new file mode 100644 index 0000000..9d0d3ec --- /dev/null +++ b/order/batch_verifier.go @@ -0,0 +1,334 @@ +package order + +import ( + "bytes" + "context" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/agora/client/account" + "github.com/lightninglabs/agora/client/clmrpc" + "github.com/lightninglabs/loop/lndclient" + "github.com/lightningnetwork/lnd/input" +) + +const ( + // deriveKeyTimeout is the number of seconds we allow the wallet to take + // to derive a key. + deriveKeyTimeout = 10 * time.Second +) + +var ( + // ErrMismatchErr is the wrapped error that is returned if the batch + // verification fails. + ErrMismatchErr = errors.New("batch verification result mismatch") +) + +// MismatchErr is an error type that is returned if the batch verification on +// the client does not come up with the same result as the server. +type MismatchErr struct { + msg string + cause error +} + +// Unwrap returns the underlying error cause. This is always ErrMismatchErr so +// we can compare any error returned by the batch verifier with errors.Is() but +// still retain the context what exactly went wrong. +func (m *MismatchErr) Unwrap() error { + return ErrMismatchErr +} + +// Error returns the underlying error message. +// +// NOTE: This method is part of the error interface. +func (m *MismatchErr) Error() string { + if m.cause == nil { + return m.msg + } + return fmt.Sprintf("%s: %v", m.msg, m.cause) +} + +// newMismatchErr return a new MismatchErr from the cause and the error message. +func newMismatchErr(cause error, msg string, args ...interface{}) error { + return &MismatchErr{ + msg: fmt.Sprintf(msg, args...), + cause: cause, + } +} + +// batchVerifier is a type that implements BatchVerifier and can verify a batch +// from the point of view of the trader. +type batchVerifier struct { + orderStore Store + getAccount func(*btcec.PublicKey) (*account.Account, error) + wallet lndclient.WalletKitClient + ourNodePubkey [33]byte +} + +// Verify makes sure the batch prepared by the server is correct and can be +// accepted by the trader. +// +// NOTE: This method is part of the BatchVerifier interface. +func (v *batchVerifier) Verify(batch *Batch) error { + // First of all, make sure we're using the same batch validation version + // as the server. Otherwise we bail out of the batch. This should + // already be handled when the client connects/authenticates. But + // doesn't hurt to check again. + if batch.Version != CurrentVersion { + return ErrVersionMismatch + } + + // First go through all orders that were matched for us. We'll make sure + // we know of the order and that the numbers check out on a high level. + accounts := make(map[[33]byte]*AccountTally) + for nonce, theirOrders := range batch.MatchedOrders { + // Find our order in the database. + ourOrder, err := v.orderStore.GetOrder(nonce) + if err != nil { + return fmt.Errorf("order %x not found: %v", nonce, err) + } + + // We'll index our account tallies by the serialized form of + // the account key so some copying is necessary first. + var ( + acctKey = ourOrder.Details().AcctKey + acctKeyRaw [33]byte + ) + if acctKey == nil { + return fmt.Errorf("account for order %x invalid", nonce) + } + copy(acctKeyRaw[:], acctKey.SerializeCompressed()) + + // Find the account the order spends from, if it isn't already + // in the cache because another order spends from it. + tally, ok := accounts[acctKeyRaw] + if !ok { + acct, err := v.getAccount(acctKey) + if err != nil { + return fmt.Errorf("account %x not found: %v", + acctKeyRaw, err) + } + tally = &AccountTally{ + Account: acct, + EndingBalance: acct.Value, + } + accounts[acctKeyRaw] = tally + } + + // Now that we know which of our orders were involved in the + // match, we can start validating the match and tally up the + // account balance, executed units and fee diffs. + unitsFilled := SupplyUnit(0) + for _, theirOrder := range theirOrders { + // Verify order compatibility and fee structure. + err = v.validateMatchedOrder( + tally, ourOrder, theirOrder, batch.ExecutionFee, + batch.ClearingPrice, + ) + if err != nil { + return newMismatchErr( + err, "error matching against order %x", + theirOrder.Order.Nonce(), + ) + } + + // Make sure there is a channel output included in the + // batch transaction that has the multisig script we + // expect. + err = v.validateChannelOutput( + batch.BatchTX, ourOrder, theirOrder, + ) + if err != nil { + return newMismatchErr( + err, "error finding channel output "+ + "for matched order %x", + theirOrder.Order.Nonce(), + ) + } + + // The match looks good, one channel output more to pay + // chain fees for. + tally.NumChansCreated++ + unitsFilled += theirOrder.UnitsFilled + } + + // Last check is to make sure our order has not been over + // filled somehow. + if unitsFilled > ourOrder.Details().UnitsUnfulfilled { + return &MismatchErr{ + msg: fmt.Sprintf("invalid units to be filled "+ + "for order %x. currently unfulfilled "+ + "%d, matched with %d in total", + ourOrder.Nonce(), + ourOrder.Details().UnitsUnfulfilled, + unitsFilled, + ), + } + } + } + + // Now that we know all the accounts that were involved in the batch, + // we can make sure we got a diff for each of them. + for _, diff := range batch.AccountDiffs { + // We only should get diffs for accounts that have orders in the + // batch. If not, something's messed up. + tally, ok := accounts[diff.AccountKeyRaw] + if !ok { + return &MismatchErr{ + msg: fmt.Sprintf("got diff for uninvolved "+ + "account %x", diff.AccountKeyRaw), + } + } + + // Now that we know how many channels were created from the + // given account, let's also account for the chain fees. + tally.ChainFees(batch.BatchTxFeeRate) + + // Even if the account output is dust, we should arrive at the + // same number with our tally as the server. + if diff.EndingBalance != tally.EndingBalance { + return &MismatchErr{ + msg: fmt.Sprintf("server sent unexpected "+ + "ending balance. got %d expected %d", + diff.EndingBalance, tally.EndingBalance), + } + } + + // Make sure the ending state of the account is correct. + err := diff.validateEndingState(batch.BatchTX, tally.Account) + if err != nil { + return newMismatchErr( + err, "account %x diff is incorrect", + diff.AccountKeyRaw, + ) + } + + // The expiry should be the same as it was before if the account + // output has been recreated (which means the expiry has been + // reset as the CSV starts counting again in the new output). + if diff.EndingState == clmrpc.AccountDiff_OUTPUT_RECREATED && + diff.Expiry != tally.Expiry { + + return &MismatchErr{ + msg: fmt.Sprintf("account %x has invalid "+ + "expiry: %d", diff.AccountKeyRaw, + diff.Expiry), + } + } + } + + // From what we can tell, the batch looks good. At least our part checks + // out at this point. + return nil +} + +// validateMatchedOrder validates our order against another trader's order and +// tallies up our order's account balance. +func (v *batchVerifier) validateMatchedOrder(tally *AccountTally, + ourOrder Order, otherOrder *MatchedOrder, executionFee FeeSchedule, + clearingPrice FixedRatePremium) error { + + // Order type must be opposite. + if otherOrder.Order.Type() == ourOrder.Type() { + return fmt.Errorf("order %x matched same type "+ + "orders", ourOrder.Nonce()) + } + + // Make sure we weren't matched to our own order. + if otherOrder.NodeKey == v.ourNodePubkey { + return fmt.Errorf("other order is an order from our node") + } + + // Verify that the durations overlap. Then tally up all the fees and + // units that were paid/accrued in this matched order pair. We can + // safely cast orders here because we made sure we have the right types + // in the previous step. + switch ours := ourOrder.(type) { + case *Ask: + other := otherOrder.Order.(*Bid) + if other.MinDuration > ours.MaxDuration { + return fmt.Errorf("order duration not overlapping " + + "for our ask") + } + + // The ask's price cannot be higher than the bid's price. + if ours.FixedRate > other.FixedRate { + return fmt.Errorf("ask price greater than bid price") + } + + // This match checks out, deduct it from the account's balance. + tally.CalcMakerDelta( + executionFee, clearingPrice, + otherOrder.UnitsFilled.ToSatoshis(), other.MinDuration, + ) + + case *Bid: + other := otherOrder.Order.(*Ask) + if other.MaxDuration < ours.MinDuration { + return fmt.Errorf("order duration not overlapping " + + "for our bid") + } + + // The ask's price cannot be higher than the bid's price. + if other.FixedRate > ours.FixedRate { + return fmt.Errorf("ask price greater than bid price") + } + + // This match checks out, deduct it from the account's balance. + tally.CalcTakerDelta( + executionFee, clearingPrice, + otherOrder.UnitsFilled.ToSatoshis(), ours.MinDuration, + ) + } + + // Everything checks out so far. + return nil +} + +// validateChannelOutput makes sure there is a channel output in the batch TX +// that spends the correct amount for the matched units to the correct multisig +// script that can be used by us to open the channel. +func (v *batchVerifier) validateChannelOutput(batchTx *wire.MsgTx, + ourOrder Order, otherOrder *MatchedOrder) error { + + // Re-derive our multisig key first. + ctxt, cancel := context.WithTimeout( + context.Background(), deriveKeyTimeout, + ) + defer cancel() + ourKey, err := v.wallet.DeriveKey( + ctxt, &ourOrder.Details().MultiSigKeyLocator, + ) + if err != nil { + return fmt.Errorf("could not derive our multisig key: %v", err) + } + + // Gather the information we expect to find in the batch TX. + expectedOutputSize := otherOrder.UnitsFilled.ToSatoshis() + _, expectedOut, err := input.GenFundingPkScript( + ourKey.PubKey.SerializeCompressed(), otherOrder.MultiSigKey[:], + int64(expectedOutputSize), + ) + if err != nil { + return fmt.Errorf("could not create multisig script: %v", err) + } + + // Locate the channel output now that we know what to look for. + for _, out := range batchTx.TxOut { + if out.Value == expectedOut.Value && + bytes.Equal(out.PkScript, expectedOut.PkScript) { + + // Bingo, this is what we want. + return nil + } + } + + return fmt.Errorf("no channel output found in batch tx for matched "+ + "order %x", otherOrder.Order.Nonce()) +} + +// A compile-time constraint to ensure batchVerifier implements BatchVerifier. +var _ BatchVerifier = (*batchVerifier)(nil) diff --git a/order/batch_verifier_test.go b/order/batch_verifier_test.go new file mode 100644 index 0000000..c7ecf9d --- /dev/null +++ b/order/batch_verifier_test.go @@ -0,0 +1,449 @@ +package order + +import ( + "context" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/lightninglabs/agora/client/account" + "github.com/lightninglabs/agora/client/clmrpc" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +var ( + _, startBatchKey = btcec.PrivKeyFromBytes(btcec.S256(), []byte{0x01}) + _, acctKeyBig = btcec.PrivKeyFromBytes(btcec.S256(), []byte{0x02}) + _, acctKeySmall = btcec.PrivKeyFromBytes(btcec.S256(), []byte{0x03}) + nodePubkey = [33]byte{03, 77, 44, 55} + execFeeBase = btcutil.Amount(1_100) + execFeeRate = btcutil.Amount(50) + clearingPrice = FixedRatePremium(5) + stateRecreated = clmrpc.AccountDiff_OUTPUT_RECREATED + stateExtendedOffchain = clmrpc.AccountDiff_OUTPUT_DUST_EXTENDED_OFFCHAIN +) + +func TestBatchVerifier(t *testing.T) { + t.Parallel() + + var ( + lnd = test.NewMockLnd() + verifier = &batchVerifier{ + wallet: lnd.WalletKit, + ourNodePubkey: nodePubkey, + } + batchID BatchID + acctIDBig [33]byte + acctIDSmall [33]byte + ) + copy(batchID[:], startBatchKey.SerializeCompressed()) + copy(acctIDBig[:], acctKeyBig.SerializeCompressed()) + copy(acctIDSmall[:], acctKeySmall.SerializeCompressed()) + + // All the test cases we want to run. The doVerify function gets passed + // in a batch that does pass validation and represents the happy path. + // The test cases can manipulate the "good" batch specifically to + // trigger validation edge cases. + testCases := []struct { + name string + expectedErr string + doVerify func(*Ask, *Bid, *Bid, *Batch) error + }{ + { + name: "version mismatch", + expectedErr: ErrVersionMismatch.Error(), + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + return verifier.Verify(&Batch{Version: 999}) + }, + }, + { + name: "invalid order", + expectedErr: "not found", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + arr := make([]*MatchedOrder, 0) + b.MatchedOrders[Nonce{99, 99}] = arr + return verifier.Verify(b) + }, + }, + { + name: "invalid order type", + expectedErr: "matched same type orders", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.MatchedOrders[a.nonce] = append( + b.MatchedOrders[a.nonce], + &MatchedOrder{ + Order: a, + }, + ) + return verifier.Verify(b) + }, + }, + { + name: "invalid node pubkey", + expectedErr: "other order is an order from our node", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.MatchedOrders[a.nonce][0].NodeKey = nodePubkey + return verifier.Verify(b) + }, + }, + { + name: "ask max duration larger than bid", + expectedErr: "duration not overlapping for our ask", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + a.MaxDuration = 100 + return verifier.Verify(b) + }, + }, + { + name: "ask fixed rate larger than bid", + expectedErr: "ask price greater than bid price", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + a.FixedRate = 20 + return verifier.Verify(b) + }, + }, + { + name: "bid min duration larger than ask", + expectedErr: "duration not overlapping for our bid", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + delete(b.MatchedOrders, a.nonce) + b2.MinDuration = 5000 + return verifier.Verify(b) + }, + }, + { + name: "bid fixed rate smaller than ask", + expectedErr: "ask price greater than bid price", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + delete(b.MatchedOrders, a.nonce) + b1.FixedRate = 5 + return verifier.Verify(b) + }, + }, + { + name: "channel output not found, wrong value", + expectedErr: "no channel output found in batch tx for", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.BatchTX.TxOut[0].Value = 123 + return verifier.Verify(b) + }, + }, + { + name: "channel output not found, wrong script", + expectedErr: "no channel output found in batch tx for", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.BatchTX.TxOut[0].PkScript = []byte{99, 88} + return verifier.Verify(b) + }, + }, + { + name: "invalid units filled", + expectedErr: "invalid units to be filled for order", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.BatchTX.TxOut[0].Value = 900_000 + b.MatchedOrders[a.nonce][0].UnitsFilled = 9 + return verifier.Verify(b) + }, + }, + { + name: "invalid funding TX fee rate", + expectedErr: "server sent unexpected ending balance", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.BatchTxFeeRate *= 2 + return verifier.Verify(b) + }, + }, + { + name: "invalid clearing price", + expectedErr: "server sent unexpected ending balance", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.ClearingPrice *= 2 + return verifier.Verify(b) + }, + }, + { + name: "invalid execution fee rate", + expectedErr: "server sent unexpected ending balance", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.ExecutionFee = NewLinearFeeSchedule(1, 1) + return verifier.Verify(b) + }, + }, + { + name: "invalid ending state", + expectedErr: "diff is incorrect: unexpected state", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.ExecutionFee = NewLinearFeeSchedule(0, 0) + b.BatchTX.TxOut[2].Value += 2220 + b.AccountDiffs[0].EndingBalance += 2220 + b.AccountDiffs[1].EndingBalance += 2220 + return verifier.Verify(b) + }, + }, + { + name: "invalid ending output", + expectedErr: "diff is incorrect: outpoint index", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + b.ExecutionFee = NewLinearFeeSchedule(0, 0) + b.BatchTX.TxOut[2].Value += 2220 + b.AccountDiffs[0].EndingBalance += 2220 + b.AccountDiffs[1].EndingBalance += 2220 + b.AccountDiffs[1].EndingState = stateRecreated + return verifier.Verify(b) + }, + }, + { + name: "happy path", + expectedErr: "", + doVerify: func(a *Ask, b1, b2 *Bid, b *Batch) error { + return verifier.Verify(b) + }, + }, + } + + // Run through all the test cases, creating a new, valid batch each + // time so no state carries over from the last run. + for _, tc := range testCases { + tc := tc + + // We'll create two accounts: A smaller one that has one ask for + // 4 units that will be completely used up. Then a larger + // account that has two bids that are both matched to the ask. + // This account is large enough to be recreated, as it only + // needs to pay for fees. + bigAcct := &account.Account{ + TraderKey: &keychain.KeyDescriptor{ + PubKey: acctKeyBig, + }, + Value: 500_000, + Expiry: 144, + State: account.StateOpen, + BatchKey: startBatchKey, + AuctioneerKey: startBatchKey, + } + smallAcct := &account.Account{ + TraderKey: &keychain.KeyDescriptor{ + PubKey: acctKeySmall, + }, + Value: 400_000, + Expiry: 144, + State: account.StateOpen, + BatchKey: startBatchKey, + AuctioneerKey: startBatchKey, + } + ask := &Ask{ + Kit: newKitFromTemplate(Nonce{0x01}, &Kit{ + MultiSigKeyLocator: keychain.KeyLocator{ + Index: 0, + }, + Units: 4, + UnitsUnfulfilled: 4, + AcctKey: acctKeySmall, + FixedRate: 10, + }), + MaxDuration: 2500, + } + bid1 := &Bid{ + Kit: newKitFromTemplate(Nonce{0x02}, &Kit{ + MultiSigKeyLocator: keychain.KeyLocator{ + Index: 1, + }, + Units: 2, + UnitsUnfulfilled: 2, + AcctKey: acctKeyBig, + FixedRate: 15, + }), + // 2000 * (200_000 * 5 / 1_000_000) = 1000 sats premium + MinDuration: 1000, + } + bid2 := &Bid{ + Kit: newKitFromTemplate(Nonce{0x03}, &Kit{ + MultiSigKeyLocator: keychain.KeyLocator{ + Index: 2, + }, + Units: 8, + UnitsUnfulfilled: 8, + AcctKey: acctKeyBig, + FixedRate: 15, + }), + // 2000 * (200_000 * 5 / 1_000_000) = 2000 sats premium + MinDuration: 2000, + } + batchTx := &wire.MsgTx{ + Version: 2, + TxOut: []*wire.TxOut{ + // Channel output for channel between ask and + // bid1. + { + Value: 200_000, + PkScript: scriptForChan( + t, lnd, ask.MultiSigKeyLocator, + bid1.MultiSigKeyLocator, + ), + }, + // Channel output for channel between ask and + // bid2. + { + Value: 200_000, + PkScript: scriptForChan( + t, lnd, ask.MultiSigKeyLocator, + bid2.MultiSigKeyLocator, + ), + }, + // Recreated account output for large account. + { + // balance - bid1Premium - bid2Premium - + // bid1ExecFee - bid2ExecFee - chainFees + // 500_000 - 1000 - 2000 - + // 1_110 - 1_110 - 186 + Value: 494_594, + PkScript: scriptForAcct(t, bigAcct), + }, + }, + } + + // Create a batch for us as if we were the trader for both + // accounts and were matched against each other (not impossible + // but unlikely to happen in the real system). + accountDiffs := []*AccountDiff{ + { + AccountKeyRaw: acctIDBig, + AccountKey: acctKeyBig, + EndingState: stateRecreated, + OutpointIndex: 2, + Expiry: bigAcct.Expiry, + EndingBalance: 494_594, + }, + { + AccountKeyRaw: acctIDSmall, + AccountKey: acctKeySmall, + EndingState: stateExtendedOffchain, + OutpointIndex: -1, + Expiry: smallAcct.Expiry, + EndingBalance: 594, + }, + } + matchedOrders := map[Nonce][]*MatchedOrder{ + ask.nonce: { + { + Order: bid1, + UnitsFilled: 2, + MultiSigKey: deriveRawKey( + t, lnd, bid1.MultiSigKeyLocator, + ), + }, + { + Order: bid2, + UnitsFilled: 2, + MultiSigKey: deriveRawKey( + t, lnd, bid2.MultiSigKeyLocator, + ), + }, + }, + bid1.nonce: {{ + Order: ask, + UnitsFilled: 2, + MultiSigKey: deriveRawKey( + t, lnd, ask.MultiSigKeyLocator, + ), + }}, + bid2.nonce: {{ + Order: ask, + UnitsFilled: 2, + MultiSigKey: deriveRawKey( + t, lnd, ask.MultiSigKeyLocator, + ), + }}, + } + batch := &Batch{ + ID: batchID, + Version: DefaultVersion, + MatchedOrders: matchedOrders, + AccountDiffs: accountDiffs, + ExecutionFee: NewLinearFeeSchedule( + execFeeBase, execFeeRate, + ), + ClearingPrice: clearingPrice, + BatchTX: batchTx, + BatchTxFeeRate: chainfee.FeePerKwFloor, + } + + // Create the starting database state now. + storeMock := newMockStore() + verifier.orderStore = storeMock + verifier.getAccount = storeMock.getAccount + storeMock.accounts = map[*btcec.PublicKey]*account.Account{ + acctKeyBig: bigAcct, + acctKeySmall: smallAcct, + } + storeMock.orders = map[Nonce]Order{ + ask.Nonce(): ask, + bid1.Nonce(): bid1, + bid2.Nonce(): bid2, + } + + // Finally run the test case itself. + t.Run(tc.name, func(t *testing.T) { + err := tc.doVerify(ask, bid1, bid2, batch) + if (err == nil && tc.expectedErr != "") || + (err != nil && !strings.Contains( + err.Error(), tc.expectedErr, + )) { + + t.Fatalf("unexpected error, got '%v' wanted "+ + "'%v'", err, tc.expectedErr) + } + }) + } +} + +func newKitFromTemplate(nonce Nonce, tpl *Kit) Kit { + kit := NewKit(nonce) + kit.Version = tpl.Version + kit.State = tpl.State + kit.FixedRate = tpl.FixedRate + kit.Amt = tpl.Amt + kit.Units = tpl.Units + kit.UnitsUnfulfilled = tpl.UnitsUnfulfilled + kit.MultiSigKeyLocator = tpl.MultiSigKeyLocator + kit.FundingFeeRate = tpl.FundingFeeRate + kit.AcctKey = tpl.AcctKey + return *kit +} + +func scriptForChan(t *testing.T, lnd *test.LndMockServices, loc1, + loc2 keychain.KeyLocator) []byte { + + key1 := deriveRawKey(t, lnd, loc1) + key2 := deriveRawKey(t, lnd, loc2) + _, out, err := input.GenFundingPkScript(key1[:], key2[:], 123) + if err != nil { + t.Fatalf("error generating funding script: %v", err) + } + return out.PkScript +} + +func deriveRawKey(t *testing.T, lnd *test.LndMockServices, + loc keychain.KeyLocator) [33]byte { + + key, err := lnd.WalletKit.DeriveKey(context.Background(), &loc) + if err != nil { + t.Fatalf("error deriving key: %v", err) + } + var rawKey [33]byte + copy(rawKey[:], key.PubKey.SerializeCompressed()) + return rawKey +} + +func scriptForAcct(t *testing.T, acct *account.Account) []byte { + script, err := acct.NextOutputScript() + if err != nil { + t.Errorf("error deriving next script: %v", err) + } + return script +} diff --git a/order/manager.go b/order/manager.go index 11d7097..3525113 100644 --- a/order/manager.go +++ b/order/manager.go @@ -6,12 +6,19 @@ import ( "net" "strings" "sync" + "time" "github.com/lightninglabs/agora/client/account" "github.com/lightninglabs/loop/lndclient" "github.com/lightningnetwork/lnd/keychain" ) +const ( + // defaultLndTimeout is the default number of seconds we are willing to + // wait for our lnd node to respond. + defaultLndTimeout = time.Second * 30 +) + var ( // ErrVersionMismatch is the error that is returned if we don't // implement the same batch verification version as the server. @@ -25,6 +32,8 @@ type ManagerConfig struct { // Store is responsible for storing and retrieving order information. Store Store + AcctStore account.Store + // Lightning is used to access the main RPC to get information about the // lnd node that agora is connected to. Lightning lndclient.LightningClient @@ -45,6 +54,10 @@ type Manager struct { wg sync.WaitGroup quit chan struct{} + + batchVerifier BatchVerifier + batchSigner BatchSigner + pendingBatch *Batch } // NewManager instantiates a new Manager backed by the given config. @@ -58,7 +71,30 @@ func NewManager(cfg *ManagerConfig) *Manager { // Start starts all concurrent tasks the manager is responsible for. func (m *Manager) Start() error { var err error - m.started.Do(func() {}) + m.started.Do(func() { + // We'll need our node's identity public key for a bunch of + // different validations so we might as well cache it on + // startup as it cannot change. + var info *lndclient.Info + ctxt, cancel := context.WithTimeout( + context.Background(), defaultLndTimeout, + ) + defer cancel() + info, err = m.cfg.Lightning.GetInfo(ctxt) + if err != nil { + return + } + m.batchVerifier = &batchVerifier{ + orderStore: m.cfg.Store, + getAccount: m.cfg.AcctStore.Account, + wallet: m.cfg.Wallet, + ourNodePubkey: info.IdentityPubkey, + } + m.batchSigner = &batchSigner{ + getAccount: m.cfg.AcctStore.Account, + signer: m.cfg.Signer, + } + }) return err } @@ -74,7 +110,7 @@ func (m *Manager) Stop() { func (m *Manager) PrepareOrder(ctx context.Context, order Order, acct *account.Account) (*ServerOrderParams, error) { - // Validate incoming request for formal validity. + // Verify incoming request for formal validity. err := m.validateOrder(order, acct) if err != nil { return nil, err @@ -175,6 +211,36 @@ func (m *Manager) validateOrder(order Order, acct *account.Account) error { return nil } +// OrderMatchValidate... +func (m *Manager) OrderMatchValidate(batch *Batch) error { + // Make sure we have no objection to the current batch. Then store + // it in case it ends up being the final version. + err := m.batchVerifier.Verify(batch) + if err != nil { + return fmt.Errorf("error validating batch: %v", err) + } + m.pendingBatch = batch + + // TODO: + // - cancel funding shim of previous pending batch if not nil + + return nil +} + +// BatchSign... +func (m *Manager) BatchSign() (BatchSignature, error) { + return m.batchSigner.Sign(m.pendingBatch) +} + +func (m *Manager) BatchFinalize(batchID BatchID) error { + // TODO: + // - update order DB + // - update account DB + // - call lnrpc.OpenChannel + m.pendingBatch = nil + return nil +} + // parseNodeUris parses a list of node URIs in the format @addr:port // as it's returned in the `lnrpc.GetInfo` request. // TODO(guggero): What is needed to support tor as well? diff --git a/order/rpc_parse.go b/order/rpc_parse.go new file mode 100644 index 0000000..e705d0b --- /dev/null +++ b/order/rpc_parse.go @@ -0,0 +1,292 @@ +package order + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/lightninglabs/agora/client/clmrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// ParseRPCOrder parses the incoming raw RPC order into the go native data +// types used in the order struct. +func ParseRPCOrder(version uint32, details *clmrpc.Order) (*Kit, error) { + var nonce Nonce + copy(nonce[:], details.OrderNonce) + kit := NewKit(nonce) + + // If the user didn't provide a nonce, we generate one. + if nonce == ZeroNonce { + preimageBytes, err := randomPreimage() + if err != nil { + return nil, fmt.Errorf("cannot generate nonce: %v", err) + } + var preimage lntypes.Preimage + copy(preimage[:], preimageBytes) + kit = NewKitWithPreimage(preimage) + } + + pubKey, err := btcec.ParsePubKey(details.UserSubKey, btcec.S256()) + if err != nil { + return nil, fmt.Errorf("error parsing account key: %v", err) + } + + kit.AcctKey = pubKey + kit.Version = Version(version) + kit.FixedRate = uint32(details.RateFixed) + kit.Amt = btcutil.Amount(details.Amt) + kit.FundingFeeRate = chainfee.SatPerKWeight(details.FundingFeeRate) + kit.Units = NewSupplyFromSats(kit.Amt) + kit.UnitsUnfulfilled = kit.Units + return kit, nil +} + +// ParseRPCServerOrder parses the incoming raw RPC server order into the go +// native data types used in the order struct. +func ParseRPCServerOrder(version uint32, details *clmrpc.ServerOrder) (*Kit, + [33]byte, []net.Addr, [33]byte, error) { + + var ( + nonce Nonce + nodeKey [33]byte + nodeAddrs = make([]net.Addr, 0, len(details.NodeAddr)) + multiSigKey [33]byte + ) + + copy(nonce[:], details.OrderNonce) + kit := NewKit(nonce) + kit.Version = Version(version) + kit.FixedRate = uint32(details.RateFixed) + kit.Amt = btcutil.Amount(details.Amt) + kit.Units = NewSupplyFromSats(kit.Amt) + kit.UnitsUnfulfilled = kit.Units + kit.FundingFeeRate = chainfee.SatPerKWeight( + details.FundingFeeRateSatPerKw, + ) + + // If the user didn't provide a nonce, we generate one. + if nonce == ZeroNonce { + preimageBytes, err := randomPreimage() + if err != nil { + return nil, nodeKey, nodeAddrs, multiSigKey, + fmt.Errorf("cannot generate nonce: %v", err) + } + var preimage lntypes.Preimage + copy(preimage[:], preimageBytes) + kit = NewKitWithPreimage(preimage) + } + + pubKey, err := btcec.ParsePubKey(details.UserSubKey, btcec.S256()) + if err != nil { + return nil, nodeKey, nodeAddrs, multiSigKey, + fmt.Errorf("error parsing account key: %v", err) + } + + kit.AcctKey = pubKey + + nodePubKey, err := btcec.ParsePubKey(details.NodePub, btcec.S256()) + if err != nil { + return nil, nodeKey, nodeAddrs, multiSigKey, + fmt.Errorf("unable to parse node pub key: %v", + err) + } + copy(nodeKey[:], nodePubKey.SerializeCompressed()) + if len(details.NodeAddr) == 0 { + return nil, nodeKey, nodeAddrs, multiSigKey, + fmt.Errorf("invalid node addresses") + } + for _, rpcAddr := range details.NodeAddr { + addr, err := net.ResolveTCPAddr(rpcAddr.Network, rpcAddr.Addr) + if err != nil { + return nil, nodeKey, nodeAddrs, multiSigKey, + fmt.Errorf("unable to parse node ddr: %v", err) + } + nodeAddrs = append(nodeAddrs, addr) + } + multiSigPubkey, err := btcec.ParsePubKey( + details.MultiSigKey, btcec.S256(), + ) + if err != nil { + return nil, nodeKey, nodeAddrs, multiSigKey, + fmt.Errorf("unable to parse multi sig pub key: %v", err) + } + copy(multiSigKey[:], multiSigPubkey.SerializeCompressed()) + + return kit, nodeKey, nodeAddrs, multiSigKey, nil +} + +// ParseRPCServerAsk parses the incoming raw RPC server ask into the go +// native data types used in the order struct. +func ParseRPCServerAsk(details *clmrpc.ServerAsk) (*MatchedOrder, error) { + var ( + o = &MatchedOrder{} + kit *Kit + err error + ) + kit, o.NodeKey, o.NodeAddrs, o.MultiSigKey, err = + ParseRPCServerOrder(details.Version, details.Details) + if err != nil { + return nil, err + } + o.Order = &Ask{ + Kit: *kit, + MaxDuration: uint32(details.MaxDurationBlocks), + } + return o, nil +} + +// ParseRPCServerBid parses the incoming raw RPC server bid into the go +// native data types used in the order struct. +func ParseRPCServerBid(details *clmrpc.ServerBid) (*MatchedOrder, error) { + var ( + o = &MatchedOrder{} + kit *Kit + err error + ) + kit, o.NodeKey, o.NodeAddrs, o.MultiSigKey, err = + ParseRPCServerOrder(details.Version, details.Details) + if err != nil { + return nil, err + } + o.Order = &Bid{ + Kit: *kit, + MinDuration: uint32(details.MinDurationBlocks), + } + return o, nil +} + +// ParseRPCBatch parses the incoming raw RPC batch into the go native data types +// used by the order manager. +func ParseRPCBatch(prepareMsg *clmrpc.OrderMatchPrepare) (*Batch, + error) { + + b := &Batch{ + Version: BatchVersion(prepareMsg.BatchVersion), + MatchedOrders: make(map[Nonce][]*MatchedOrder), + BatchTX: &wire.MsgTx{}, + } + + // Parse matched orders. + for ourOrderHex, rpcMatchedOrders := range prepareMsg.MatchedOrders { + var ourOrder Nonce + ourOrderBytes, err := hex.DecodeString(ourOrderHex) + if err != nil { + return nil, fmt.Errorf("error parsing nonce: %v", err) + } + copy(ourOrder[:], ourOrderBytes) + b.MatchedOrders[ourOrder], err = ParseRPCMatchedOrders( + rpcMatchedOrders, + ) + if err != nil { + return nil, fmt.Errorf("error parsing matched order: "+ + "%v", err) + } + } + + // Parse account diff. + for _, diff := range prepareMsg.ChargedAccounts { + var acctKeyRaw [33]byte + acctKey, err := btcec.ParsePubKey(diff.UserSubKey, btcec.S256()) + if err != nil { + return nil, fmt.Errorf("error parsing account key: %v", + err) + } + copy(acctKeyRaw[:], acctKey.SerializeCompressed()) + b.AccountDiffs = append( + b.AccountDiffs, &AccountDiff{ + AccountKeyRaw: acctKeyRaw, + AccountKey: acctKey, + EndingState: diff.EndingState, + EndingBalance: btcutil.Amount(diff.EndingBalance), + OutpointIndex: diff.OutpointIndex, + Expiry: diff.Expiry, + }, + ) + } + + // Parse batch transaction. + err := b.BatchTX.Deserialize(bytes.NewReader( + prepareMsg.BatchTransaction, + )) + if err != nil { + return nil, fmt.Errorf("error parsing batch TX: %v", err) + } + + // Convert clearing price, fee rate and rebate. + b.ClearingPrice = FixedRatePremium(prepareMsg.ClearingPriceRate) + b.BatchTxFeeRate = chainfee.SatPerKWeight(prepareMsg.FeeRateSatPerKw) + b.FeeRebate = btcutil.Amount(prepareMsg.FeeRebateSat) + + // Parse the execution fee. + if prepareMsg.ExecutionFee == nil { + return nil, fmt.Errorf("execution fee missing") + } + b.ExecutionFee = NewLinearFeeSchedule( + btcutil.Amount(prepareMsg.ExecutionFee.BaseFee), + btcutil.Amount(prepareMsg.ExecutionFee.FeeRate), + ) + + // Parse the batch ID as public key just to make sure it's valid. + _, err = btcec.ParsePubKey(prepareMsg.BatchId, btcec.S256()) + if err != nil { + return nil, fmt.Errorf("error parsing batch ID: %v", err) + } + copy(b.ID[:], prepareMsg.BatchId) + + return b, nil +} + +// ParseRPCMatchedOrders parses the incoming raw RPC matched orders into the go +// native structs used by the order manager. +func ParseRPCMatchedOrders(orders *clmrpc.MatchedOrder) ([]*MatchedOrder, + error) { + + var result []*MatchedOrder + // The only thing we can check in this step is that not both matched + // bids and matched asks are set at the same time as that wouldn't make + // sense. Everything else is checked at a later stage when we know more + // about our order that was matched against. + switch { + case len(orders.MatchedAsks) > 0 && len(orders.MatchedBids) > 0: + return nil, fmt.Errorf("order cannot match both asks and bids") + + case len(orders.MatchedAsks) > 0: + for _, ask := range orders.MatchedAsks { + matchedAsk, err := ParseRPCServerAsk(ask.Ask) + if err != nil { + return nil, fmt.Errorf("error parsing server "+ + "ask: %v", err) + } + result = append(result, matchedAsk) + } + + case len(orders.MatchedBids) > 0: + for _, bid := range orders.MatchedBids { + matchedBid, err := ParseRPCServerBid(bid.Bid) + if err != nil { + return nil, fmt.Errorf("error parsing server "+ + "bid: %v", err) + } + result = append(result, matchedBid) + } + } + + return result, nil +} + +// randomPreimage creates a new preimage from a random number generator. +func randomPreimage() ([]byte, error) { + var nonce Nonce + _, err := rand.Read(nonce[:]) + if err != nil { + return nil, err + } + return nonce[:], nil +} diff --git a/order/tradingfees.go b/order/tradingfees.go index c3c7b69..d895c46 100644 --- a/order/tradingfees.go +++ b/order/tradingfees.go @@ -4,6 +4,7 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcutil" "github.com/btcsuite/btcwallet/wallet/txrules" + "github.com/lightninglabs/agora/client/account" "github.com/lightninglabs/agora/client/clmscript" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnwallet/chainfee" @@ -150,6 +151,9 @@ func EstimateTraderFee(numTraderChans uint32, // AccountTally keeps track of an account's balance and fees for all orders in // a batch that spend from/use that account. type AccountTally struct { + // Account is the embedded account this tally is related to. + *account.Account + // EndingBalance is the ending balance for a trader's account. EndingBalance btcutil.Amount diff --git a/rpcserver.go b/rpcserver.go index a4cf4de..5c7d7d3 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -2,8 +2,8 @@ package client import ( "context" - "crypto/rand" "encoding/hex" + "errors" "fmt" "sync" "sync/atomic" @@ -21,8 +21,6 @@ import ( "github.com/lightninglabs/agora/client/clmrpc" "github.com/lightninglabs/agora/client/order" "github.com/lightninglabs/loop/lndclient" - "github.com/lightningnetwork/lnd/lntypes" - "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) const ( @@ -79,6 +77,7 @@ func newRPCServer(server *Server, serverDir string) (*rpcServer, error) { }), orderManager: order.NewManager(&order.ManagerConfig{ Store: db, + AcctStore: db, Lightning: lnd.Client, Wallet: lnd.WalletKit, Signer: lnd.Signer, @@ -248,39 +247,63 @@ func (s *rpcServer) handleServerMessage(rpcMsg *clmrpc.ServerAuctionMessage) err switch msg := rpcMsg.Msg.(type) { // A new batch has been assembled with some of our orders. case *clmrpc.ServerAuctionMessage_Prepare: + // Parse and formally validate what we got from the server. log.Tracef("Received prepare msg from server, batch_id=%x: %v", msg.Prepare.BatchId, spew.Sdump(msg)) - - // TODO(guggero): Add real batch validation here. - // For now, we just send the accept back. - err := s.auctioneer.SendAuctionMessage(&clmrpc.ClientAuctionMessage{ - Msg: &clmrpc.ClientAuctionMessage_Accept{ - Accept: &clmrpc.OrderMatchAccept{ - BatchId: msg.Prepare.BatchId, - }, - }, - }) + batch, err := order.ParseRPCBatch(msg.Prepare) if err != nil { - return err + return fmt.Errorf("error parsing RPC batch: %v", err) } - // TODO(guggero): Initiate channel opening negotiation with - // remote peer here. - err = s.auctioneer.SendAuctionMessage(&clmrpc.ClientAuctionMessage{ - Msg: &clmrpc.ClientAuctionMessage_Sign{ - Sign: &clmrpc.OrderMatchSign{ - BatchId: msg.Prepare.BatchId, - }, - }, - }) + // Do an in-depth verification of the batch. + err = s.orderManager.OrderMatchValidate(batch) if err != nil { - return err + // We can't accept the batch, something went wrong. + log.Errorf("Error validating batch: %v", err) + return s.sendRejectBatch(batch, err) } + // Accept the match now. + // + // TODO(guggero): Give user the option to bail out of any order + // up to this point? + err = s.sendAcceptBatch(batch) + if err != nil { + log.Errorf("Error sending accept msg: %v", err) + return s.sendRejectBatch(batch, err) + } + + // We were able to accept the batch. Inform the auctioneer, then + // start negotiating with the remote peers. We'll sign once all + // channel partners have responded. + err = s.server.BatchChannelSetup(batch) + if err != nil { + log.Errorf("Error setting up channels: %v", err) + return s.sendRejectBatch(batch, err) + } + + // Sign for the accounts in the batch. + sigs, err := s.orderManager.BatchSign() + if err != nil { + log.Errorf("Error signing batch: %v", err) + return s.sendRejectBatch(batch, err) + } + err = s.sendSignBatch(batch, sigs) + if err != nil { + log.Errorf("Error sending sign msg: %v", err) + return s.sendRejectBatch(batch, err) + } + + // The previously prepared batch has been executed and we can finalize + // it by opening the channel and persisting the account and order diffs. case *clmrpc.ServerAuctionMessage_Finalize: log.Tracef("Received finalize msg from server, batch_id=%x: %v", msg.Finalize.BatchId, spew.Sdump(msg)) + var batchID order.BatchID + copy(batchID[:], msg.Finalize.BatchId) + return s.orderManager.BatchFinalize(batchID) + default: return fmt.Errorf("unknown server message: %v", msg) } @@ -419,7 +442,7 @@ func (s *rpcServer) SubmitOrder(ctx context.Context, switch requestOrder := req.Details.(type) { case *clmrpc.SubmitOrderRequest_Ask: a := requestOrder.Ask - kit, err := parseRPCOrder(a.Version, a.Details) + kit, err := order.ParseRPCOrder(a.Version, a.Details) if err != nil { return nil, err } @@ -430,7 +453,7 @@ func (s *rpcServer) SubmitOrder(ctx context.Context, case *clmrpc.SubmitOrderRequest_Bid: b := requestOrder.Bid - kit, err := parseRPCOrder(b.Version, b.Details) + kit, err := order.ParseRPCOrder(b.Version, b.Details) if err != nil { return nil, err } @@ -572,45 +595,86 @@ func (s *rpcServer) CancelOrder(ctx context.Context, return &clmrpc.CancelOrderResponse{}, nil } -// parseRPCOrder parses the incoming raw RPC order into the go native data -// types used in the order struct. -func parseRPCOrder(version uint32, details *clmrpc.Order) (*order.Kit, error) { - var nonce order.Nonce - copy(nonce[:], details.OrderNonce) - kit := order.NewKit(nonce) +// sendRejectBatch sends a reject message to the server with the properly +// decoded reason code and the full reason message as a string. +func (s *rpcServer) sendRejectBatch(batch *order.Batch, failure error) error { + msg := &clmrpc.ClientAuctionMessage_Reject{ + Reject: &clmrpc.OrderMatchReject{ + BatchId: batch.ID[:], + Reason: failure.Error(), + }, + } - // If the user didn't provide a nonce, we generate one. - if nonce == order.ZeroNonce { - preimageBytes, err := randomPreimage() - if err != nil { - return nil, fmt.Errorf("cannot generate nonce: %v", err) + // Attach the status code to the message to give a bit more context. + switch { + case errors.Is(failure, order.ErrVersionMismatch): + msg.Reject.ReasonCode = clmrpc.OrderMatchReject_BATCH_VERSION_MISMATCH + + case errors.Is(failure, order.ErrMismatchErr): + msg.Reject.ReasonCode = clmrpc.OrderMatchReject_SERVER_MISBEHAVIOR + + default: + msg.Reject.ReasonCode = clmrpc.OrderMatchReject_UNKNOWN + } + log.Infof("Sending batch rejection message for batch %x with "+ + "code %v and message: %v", batch.ID, msg.Reject.ReasonCode, + failure) + + // Send the message to the server. If a new error happens we return that + // one because we know the causing error has at least been logged at + // some point before. + err := s.auctioneer.SendAuctionMessage(&clmrpc.ClientAuctionMessage{ + Msg: msg, + }) + if err != nil { + return fmt.Errorf("error sending reject message: %v", err) + } + return failure +} + +// sendAcceptBatch sends an accept message to the server with the list of order +// nonces that we accept in the batch. +func (s *rpcServer) sendAcceptBatch(batch *order.Batch) error { + // Prepare the list of nonces we accept by serializing them to a slice + // of byte slices. + nonces := make([][]byte, 0, len(batch.MatchedOrders)) + idx := 0 + for nonce := range batch.MatchedOrders { + nonces[idx] = nonce[:] + idx++ + } + + // Send the message to the server. + return s.auctioneer.SendAuctionMessage(&clmrpc.ClientAuctionMessage{ + Msg: &clmrpc.ClientAuctionMessage_Accept{ + Accept: &clmrpc.OrderMatchAccept{ + BatchId: batch.ID[:], + OrderNonce: nonces, + }, + }, + }) +} + +// sendSignBatch sends a sign message to the server with the witness stacks of +// all accounts that are involved in the batch. +func (s *rpcServer) sendSignBatch(batch *order.Batch, + sigs order.BatchSignature) error { + + // Prepare the list of witness stack messages and send them to the + // server. + rpcSigs := make(map[string]*clmrpc.AccountWitness) + for acctKey, witness := range sigs { + key := hex.EncodeToString(acctKey[:]) + rpcSigs[key] = &clmrpc.AccountWitness{ + Witness: witness, } - var preimage lntypes.Preimage - copy(preimage[:], preimageBytes) - kit = order.NewKitWithPreimage(preimage) } - - pubKey, err := btcec.ParsePubKey(details.UserSubKey, btcec.S256()) - if err != nil { - return nil, fmt.Errorf("error parsing account key: %v", err) - } - - kit.AcctKey = pubKey - kit.Version = order.Version(version) - kit.FixedRate = uint32(details.RateFixed) - kit.Amt = btcutil.Amount(details.Amt) - kit.FundingFeeRate = chainfee.SatPerKWeight(details.FundingFeeRate) - kit.Units = order.NewSupplyFromSats(kit.Amt) - kit.UnitsUnfulfilled = kit.Units - return kit, nil -} - -// randomPreimage creates a new preimage from a random number generator. -func randomPreimage() ([]byte, error) { - var nonce order.Nonce - _, err := rand.Read(nonce[:]) - if err != nil { - return nil, err - } - return nonce[:], nil + return s.auctioneer.SendAuctionMessage(&clmrpc.ClientAuctionMessage{ + Msg: &clmrpc.ClientAuctionMessage_Sign{ + Sign: &clmrpc.OrderMatchSign{ + BatchId: batch.ID[:], + AccountWitness: rpcSigs, + }, + }, + }) } diff --git a/server.go b/server.go index 7d2bd3e..ef5b4dd 100644 --- a/server.go +++ b/server.go @@ -12,6 +12,7 @@ import ( proxy "github.com/grpc-ecosystem/grpc-gateway/runtime" "github.com/lightninglabs/agora/client/auctioneer" "github.com/lightninglabs/agora/client/clmrpc" + "github.com/lightninglabs/agora/client/order" "github.com/lightninglabs/loop/lndclient" "github.com/lightninglabs/loop/lsat" "github.com/lightningnetwork/lnd/build" @@ -230,6 +231,14 @@ func (s *Server) Stop() error { return nil } +// BatchChannelSetup... +func (s *Server) BatchChannelSetup(batch *order.Batch) error { + // TODO: + // - connect to peers of new channels + // - register funding shim in lnd + return nil +} + // getLnd returns an instance of the lnd services proxy. func getLnd(network string, cfg *LndConfig) (*lndclient.GrpcLndServices, error) { return lndclient.NewLndServices(