From 53061228755c9fc80e890e5be74ba2f1085f0d0f Mon Sep 17 00:00:00 2001 From: elnosh Date: Mon, 12 Aug 2024 16:19:19 -0500 Subject: [PATCH] mint - tests for NUT11 support --- cashu/nuts/nut11/nut11.go | 34 +++++++- mint/mint.go | 124 +++++++++++++--------------- mint/mint_integration_test.go | 142 ++++++++++++++++++++++++++++++++ testutils/utils.go | 148 ++++++++++++++++++++++++++++++++++ wallet/wallet.go | 3 +- 5 files changed, 379 insertions(+), 72 deletions(-) diff --git a/cashu/nuts/nut11/nut11.go b/cashu/nuts/nut11/nut11.go index e565faa..6aa4d80 100644 --- a/cashu/nuts/nut11/nut11.go +++ b/cashu/nuts/nut11/nut11.go @@ -46,7 +46,7 @@ var ( TooManyTagsErr = cashu.Error{Detail: "too many tags", Code: NUT11ErrCode} NSigsMustBePositiveErr = cashu.Error{Detail: "n_sigs must be a positive integer", Code: NUT11ErrCode} EmptyPubkeysErr = cashu.Error{Detail: "pubkeys tag cannot be empty if n_sigs tag is present", Code: NUT11ErrCode} - EmptyWitnessErr = cashu.Error{Detail: "witness cannot be empty", Code: NUT11ErrCode} + InvalidWitness = cashu.Error{Detail: "invalid witness", Code: NUT11ErrCode} NotEnoughSignaturesErr = cashu.Error{Detail: "not enough valid signatures provided", Code: NUT11ErrCode} AllSigAllFlagsErr = cashu.Error{Detail: "all flags must be SIG_ALL", Code: NUT11ErrCode} SigAllKeysMustBeEqualErr = cashu.Error{Detail: "all public keys must be the same for SIG_ALL", Code: NUT11ErrCode} @@ -68,7 +68,7 @@ type P2PKTags struct { // P2PKSecret returns a secret with a spending condition // that will lock ecash to a public key -func P2PKSecret(pubkey string) (string, error) { +func P2PKSecret(pubkey string, p2pkTags P2PKTags) (string, error) { // generate random nonce nonceBytes := make([]byte, 32) _, err := rand.Read(nonceBytes) @@ -77,9 +77,39 @@ func P2PKSecret(pubkey string) (string, error) { } nonce := hex.EncodeToString(nonceBytes) + var tags [][]string + if len(p2pkTags.Sigflag) > 0 { + tags = append(tags, []string{SIGFLAG, p2pkTags.Sigflag}) + } + if p2pkTags.NSigs > 0 { + numStr := strconv.Itoa(p2pkTags.NSigs) + tags = append(tags, []string{NSIGS, numStr}) + } + if len(p2pkTags.Pubkeys) > 0 { + pubkeys := []string{PUBKEYS} + for _, pubkey := range p2pkTags.Pubkeys { + key := hex.EncodeToString(pubkey.SerializeCompressed()) + pubkeys = append(pubkeys, key) + } + tags = append(tags, pubkeys) + } + if p2pkTags.Locktime > 0 { + locktime := strconv.Itoa(int(p2pkTags.Locktime)) + tags = append(tags, []string{LOCKTIME, locktime}) + } + if len(p2pkTags.Refund) > 0 { + refundKeys := []string{REFUND} + for _, pubkey := range p2pkTags.Refund { + key := hex.EncodeToString(pubkey.SerializeCompressed()) + refundKeys = append(refundKeys, key) + } + tags = append(tags, refundKeys) + } + secretData := nut10.WellKnownSecret{ Nonce: nonce, Data: pubkey, + Tags: tags, } secret, err := nut10.SerializeSecret(nut10.P2PK, secretData) diff --git a/mint/mint.go b/mint/mint.go index 5a62fa3..8d4bd44 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -623,11 +623,7 @@ func verifyP2PKLockedProof(proof cashu.Proof) error { var p2pkWitness nut11.P2PKWitness err = json.Unmarshal([]byte(proof.Witness), &p2pkWitness) if err != nil { - errmsg := fmt.Sprintf("invalid witness: %v", err) - return cashu.BuildCashuError(errmsg, nut11.NUT11ErrCode) - } - if len(p2pkWitness.Signatures) < 1 { - return nut11.EmptyWitnessErr + p2pkWitness.Signatures = []string{} } p2pkTags, err := nut11.ParseP2PKTags(p2pkWellKnownSecret.Tags) @@ -643,6 +639,9 @@ func verifyP2PKLockedProof(proof cashu.Proof) error { return nil } else { hash := sha256.Sum256([]byte(proof.Secret)) + if len(p2pkWitness.Signatures) < 1 { + return nut11.InvalidWitness + } if !nut11.HasValidSignatures(hash[:], p2pkWitness, signaturesRequired, p2pkTags.Refund) { return nut11.NotEnoughSignaturesErr } @@ -664,6 +663,9 @@ func verifyP2PKLockedProof(proof cashu.Proof) error { keys = append(keys, p2pkTags.Pubkeys...) } + if len(p2pkWitness.Signatures) < 1 { + return nut11.InvalidWitness + } if !nut11.HasValidSignatures(hash[:], p2pkWitness, signaturesRequired, keys) { return nut11.NotEnoughSignaturesErr } @@ -672,90 +674,76 @@ func verifyP2PKLockedProof(proof cashu.Proof) error { } func verifyP2PKBlindedMessages(proofs cashu.Proofs, blindedMessages cashu.BlindedMessages) error { - isSigAll := false - for _, proof := range proofs { - secret, err := nut10.DeserializeSecret(proof.Secret) - if err != nil { - continue - } + secret, err := nut10.DeserializeSecret(proofs[0].Secret) + if err != nil { + return cashu.BuildCashuError(err.Error(), cashu.StandardErrCode) + } + pubkeys, err := nut11.PublicKeys(secret) + if err != nil { + return err + } - if nut11.IsSigAll(secret) { - isSigAll = true - break - } + signaturesRequired := 1 + p2pkTags, err := nut11.ParseP2PKTags(secret.Tags) + if err != nil { + return err + } + if p2pkTags.NSigs > 0 { + signaturesRequired = p2pkTags.NSigs } - if isSigAll { - secret, err := nut10.DeserializeSecret(proofs[0].Secret) + // Check that the conditions across all proofs are the same + for _, proof := range proofs { + secret, err := nut10.DeserializeSecret(proof.Secret) if err != nil { return cashu.BuildCashuError(err.Error(), cashu.StandardErrCode) } - pubkeys, err := nut11.PublicKeys(secret) - if err != nil { - return err + // all flags need to be SIG_ALL + if !nut11.IsSigAll(secret) { + return nut11.AllSigAllFlagsErr } - signaturesRequired := 1 + currentSignaturesRequired := 1 p2pkTags, err := nut11.ParseP2PKTags(secret.Tags) if err != nil { return err } if p2pkTags.NSigs > 0 { - signaturesRequired = p2pkTags.NSigs + currentSignaturesRequired = p2pkTags.NSigs } - for _, proof := range proofs { - secret, err := nut10.DeserializeSecret(proof.Secret) - if err != nil { - return cashu.BuildCashuError(err.Error(), cashu.StandardErrCode) - } - // all flags need to be SIG_ALL - if !nut11.IsSigAll(secret) { - return nut11.AllSigAllFlagsErr - } - - currentSignaturesRequired := 1 - p2pkTags, err := nut11.ParseP2PKTags(secret.Tags) - if err != nil { - return err - } - if p2pkTags.NSigs > 0 { - currentSignaturesRequired = p2pkTags.NSigs - } - - currentKeys, err := nut11.PublicKeys(secret) - if err != nil { - return err - } + currentKeys, err := nut11.PublicKeys(secret) + if err != nil { + return err + } - // list of valid keys should be the same - // across all proofs - if !reflect.DeepEqual(pubkeys, currentKeys) { - return nut11.SigAllKeysMustBeEqualErr - } + // list of valid keys should be the same + // across all proofs + if !reflect.DeepEqual(pubkeys, currentKeys) { + return nut11.SigAllKeysMustBeEqualErr + } - // all n_sigs must be same - if signaturesRequired != currentSignaturesRequired { - return nut11.NSigsMustBeEqualErr - } + // all n_sigs must be same + if signaturesRequired != currentSignaturesRequired { + return nut11.NSigsMustBeEqualErr } + } - for _, bm := range blindedMessages { - hash := sha256.Sum256([]byte(bm.B_)) + for _, bm := range blindedMessages { + B_bytes, err := hex.DecodeString(bm.B_) + if err != nil { + return cashu.BuildCashuError(err.Error(), cashu.StandardErrCode) + } + hash := sha256.Sum256(B_bytes) - var witness nut11.P2PKWitness - err := json.Unmarshal([]byte(bm.Witness), &witness) - if err != nil { - errmsg := fmt.Sprintf("invalid witness: %v", err) - return cashu.BuildCashuError(errmsg, nut11.NUT11ErrCode) - } - if len(witness.Signatures) < 1 { - return nut11.EmptyWitnessErr - } + var witness nut11.P2PKWitness + err = json.Unmarshal([]byte(bm.Witness), &witness) + if err != nil || len(witness.Signatures) < 1 { + return nut11.InvalidWitness + } - if !nut11.HasValidSignatures(hash[:], witness, signaturesRequired, pubkeys) { - return nut11.NotEnoughSignaturesErr - } + if !nut11.HasValidSignatures(hash[:], witness, signaturesRequired, pubkeys) { + return nut11.NotEnoughSignaturesErr } } diff --git a/mint/mint_integration_test.go b/mint/mint_integration_test.go index 9b7d4b6..c75a8b8 100644 --- a/mint/mint_integration_test.go +++ b/mint/mint_integration_test.go @@ -11,11 +11,14 @@ import ( "os" "path/filepath" "testing" + "time" + "github.com/btcsuite/btcd/btcec/v2" btcdocker "github.com/elnosh/btc-docker-test" "github.com/elnosh/gonuts/cashu" "github.com/elnosh/gonuts/cashu/nuts/nut04" "github.com/elnosh/gonuts/cashu/nuts/nut05" + "github.com/elnosh/gonuts/cashu/nuts/nut11" "github.com/elnosh/gonuts/crypto" "github.com/elnosh/gonuts/mint" "github.com/elnosh/gonuts/testutils" @@ -658,5 +661,144 @@ func TestMintLimits(t *testing.T) { if err != nil { t.Fatalf("got unexpected error requesting mint quote: %v", err) } +} + +func TestNUT11P2PK(t *testing.T) { + lock, _ := btcec.NewPrivateKey() + + p2pkMintPath := filepath.Join(".", "p2pkmint") + p2pkMint, err := testutils.CreateTestMint(lnd1, p2pkMintPath, dbMigrationPath, 0, mint.MintLimits{}) + if err != nil { + t.Fatal(err) + } + defer func() { + os.RemoveAll(p2pkMintPath) + }() + + keyset := p2pkMint.GetActiveKeyset() + + var mintAmount uint64 = 1500 + lockedProofs, err := testutils.GetProofsWithLock(mintAmount, lock.PubKey(), nut11.P2PKTags{}, p2pkMint, lnd2) + if err != nil { + t.Fatalf("error getting locked proofs: %v", err) + } + blindedMessages, _, _, _ := testutils.CreateBlindedMessages(mintAmount, keyset) + + // swap with proofs that do not have valid witness + _, err = p2pkMint.Swap(lockedProofs, blindedMessages) + if !errors.Is(err, nut11.InvalidWitness) { + t.Fatalf("expected error '%v' but got '%v' instead", nut11.InvalidWitness, err) + } + + // invalid proofs signed with another key + anotherKey, _ := btcec.NewPrivateKey() + invalidProofs, _ := nut11.AddSignatureToInputs(lockedProofs, anotherKey) + _, err = p2pkMint.Swap(invalidProofs, blindedMessages) + if !errors.Is(err, nut11.NotEnoughSignaturesErr) { + t.Fatalf("expected error '%v' but got '%v' instead", nut11.NotEnoughSignaturesErr, err) + } + + // valid signed proofs + signedProofs, _ := nut11.AddSignatureToInputs(lockedProofs, lock) + _, err = p2pkMint.Swap(signedProofs, blindedMessages) + if err != nil { + t.Fatalf("unexpected error in swap: %v", err) + } + + // test multisig + key1, _ := btcec.NewPrivateKey() + key2, _ := btcec.NewPrivateKey() + multisigKeys := []*btcec.PublicKey{key1.PubKey(), key2.PubKey()} + tags := nut11.P2PKTags{ + Sigflag: nut11.SIGALL, + NSigs: 2, + Pubkeys: multisigKeys, + } + multisigProofs, err := testutils.GetProofsWithLock(mintAmount, lock.PubKey(), tags, p2pkMint, lnd2) + if err != nil { + t.Fatalf("error getting locked proofs: %v", err) + } + + // proofs with only 1 signature but require 2 + blindedMessages, _, _, _ = testutils.CreateBlindedMessages(mintAmount, keyset) + notEnoughSigsProofs, _ := nut11.AddSignatureToInputs(multisigProofs, lock) + _, err = p2pkMint.Swap(notEnoughSigsProofs, blindedMessages) + if !errors.Is(err, nut11.NotEnoughSignaturesErr) { + t.Fatalf("expected error '%v' but got '%v' instead", nut11.NotEnoughSignaturesErr, err) + } + + signingKeys := []*btcec.PrivateKey{key1, key2} + // enough signatures but blinded messages not signed + signedProofs, _ = testutils.AddSignaturesToInputs(multisigProofs, signingKeys) + _, err = p2pkMint.Swap(signedProofs, blindedMessages) + if !errors.Is(err, nut11.InvalidWitness) { + t.Fatalf("expected error '%v' but got '%v' instead", nut11.InvalidWitness, err) + } + + // inputs and outputs with valid signatures + signedBlindedMessages, _ := testutils.AddSignaturesToOutputs(blindedMessages, signingKeys) + _, err = p2pkMint.Swap(signedProofs, signedBlindedMessages) + if err != nil { + t.Fatalf("unexpected error in swap: %v", err) + } + + // test with locktime + tags = nut11.P2PKTags{ + Locktime: time.Now().Add(time.Minute * 1).Unix(), + } + locktimeProofs, err := testutils.GetProofsWithLock(mintAmount, lock.PubKey(), tags, p2pkMint, lnd2) + if err != nil { + t.Fatalf("error getting locked proofs: %v", err) + } + blindedMessages, _, _, _ = testutils.CreateBlindedMessages(mintAmount, keyset) + // unsigned proofs + _, err = p2pkMint.Swap(locktimeProofs, blindedMessages) + if !errors.Is(err, nut11.InvalidWitness) { + t.Fatalf("expected error '%v' but got '%v' instead", nut11.InvalidWitness, err) + } + + signedProofs, _ = nut11.AddSignatureToInputs(locktimeProofs, lock) + _, err = p2pkMint.Swap(signedProofs, blindedMessages) + if err != nil { + t.Fatalf("unexpected error in swap: %v", err) + } + + tags = nut11.P2PKTags{ + Locktime: time.Now().Add(-(time.Minute * 10)).Unix(), + } + locktimeProofs, err = testutils.GetProofsWithLock(mintAmount, lock.PubKey(), tags, p2pkMint, lnd2) + if err != nil { + t.Fatalf("error getting locked proofs: %v", err) + } + + blindedMessages, _, _, _ = testutils.CreateBlindedMessages(mintAmount, keyset) + // locktime expired so spendable without signature + _, err = p2pkMint.Swap(locktimeProofs, blindedMessages) + if err != nil { + t.Fatalf("unexpected error in swap: %v", err) + } + + // test locktime expired but with refund keys + tags = nut11.P2PKTags{ + Locktime: time.Now().Add(-(time.Minute * 10)).Unix(), + Refund: []*btcec.PublicKey{key1.PubKey()}, + } + locktimeProofs, err = testutils.GetProofsWithLock(mintAmount, lock.PubKey(), tags, p2pkMint, lnd2) + if err != nil { + t.Fatalf("error getting locked proofs: %v", err) + } + // unsigned proofs should fail because there were refund pubkeys in the tags + _, err = p2pkMint.Swap(locktimeProofs, blindedMessages) + if err == nil { + t.Fatal("expected error but got 'nil' instead") + } + + // sign with refund pubkey + signedProofs, _ = testutils.AddSignaturesToInputs(locktimeProofs, []*btcec.PrivateKey{key1}) + blindedMessages, _, _, _ = testutils.CreateBlindedMessages(mintAmount, keyset) + _, err = p2pkMint.Swap(signedProofs, blindedMessages) + if err != nil { + t.Fatalf("unexpected error in swap: %v", err) + } } diff --git a/testutils/utils.go b/testutils/utils.go index 5a6b931..2e1c442 100644 --- a/testutils/utils.go +++ b/testutils/utils.go @@ -3,18 +3,23 @@ package testutils import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "os" "path/filepath" "time" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/decred/dcrd/dcrec/secp256k1/v4" btcdocker "github.com/elnosh/btc-docker-test" "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut11" "github.com/elnosh/gonuts/crypto" "github.com/elnosh/gonuts/mint" "github.com/elnosh/gonuts/mint/lightning" @@ -329,6 +334,47 @@ func CreateBlindedMessages(amount uint64, keyset crypto.MintKeyset) (cashu.Blind return blindedMessages, secrets, rs, nil } +func CreateP2PKLockedBlindedMessages( + amount uint64, + keyset crypto.MintKeyset, + publicKey *btcec.PublicKey, + tags nut11.P2PKTags, +) (cashu.BlindedMessages, []string, []*secp256k1.PrivateKey, error) { + splitAmounts := cashu.AmountSplit(amount) + splitLen := len(splitAmounts) + + blindedMessages := make(cashu.BlindedMessages, splitLen) + secrets := make([]string, splitLen) + rs := make([]*secp256k1.PrivateKey, splitLen) + + pubkey := hex.EncodeToString(publicKey.SerializeCompressed()) + + for i, amt := range splitAmounts { + // generate new private key r + r, err := secp256k1.GeneratePrivateKey() + if err != nil { + return nil, nil, nil, err + } + + secret, err := nut11.P2PKSecret(pubkey, tags) + if err != nil { + return nil, nil, nil, err + } + + B_, r, err := crypto.BlindMessage(secret, r) + if err != nil { + return nil, nil, nil, err + } + + blindedMessage := newBlindedMessage(keyset.Id, amt, B_) + blindedMessages[i] = blindedMessage + secrets[i] = secret + rs[i] = r + } + + return blindedMessages, secrets, rs, nil +} + func ConstructProofs(blindedSignatures cashu.BlindedSignatures, secrets []string, rs []*secp256k1.PrivateKey, keyset *crypto.MintKeyset) (cashu.Proofs, error) { @@ -400,6 +446,108 @@ func GetValidProofsForAmount(amount uint64, mint *mint.Mint, payer *btcdocker.Ln return proofs, nil } +func GetProofsWithLock( + amount uint64, + publicKey *btcec.PublicKey, + tags nut11.P2PKTags, + mint *mint.Mint, + payer *btcdocker.Lnd, +) (cashu.Proofs, error) { + mintQuoteResponse, err := mint.RequestMintQuote(BOLT11_METHOD, amount, SAT_UNIT) + if err != nil { + return nil, fmt.Errorf("error requesting mint quote: %v", err) + } + + keyset := mint.GetActiveKeyset() + + blindedMessages, secrets, rs, err := CreateP2PKLockedBlindedMessages(amount, keyset, publicKey, tags) + if err != nil { + return nil, fmt.Errorf("error creating blinded message: %v", err) + } + + ctx := context.Background() + //pay invoice + sendPaymentRequest := lnrpc.SendRequest{ + PaymentRequest: mintQuoteResponse.PaymentRequest, + } + response, _ := payer.Client.SendPaymentSync(ctx, &sendPaymentRequest) + if len(response.PaymentError) > 0 { + return nil, fmt.Errorf("error paying invoice: %v", response.PaymentError) + } + + blindedSignatures, err := mint.MintTokens(BOLT11_METHOD, mintQuoteResponse.Id, blindedMessages) + if err != nil { + return nil, fmt.Errorf("got unexpected error minting tokens: %v", err) + } + + proofs, err := ConstructProofs(blindedSignatures, secrets, rs, &keyset) + if err != nil { + return nil, fmt.Errorf("error constructing proofs: %v", err) + } + + return proofs, nil +} + +func AddSignaturesToInputs(inputs cashu.Proofs, signingKeys []*btcec.PrivateKey) (cashu.Proofs, error) { + for i, proof := range inputs { + hash := sha256.Sum256([]byte(proof.Secret)) + signatures := make([]string, len(signingKeys)) + + for j, key := range signingKeys { + signature, err := schnorr.Sign(key, hash[:]) + if err != nil { + return nil, err + } + sig := hex.EncodeToString(signature.Serialize()) + signatures[j] = sig + } + + p2pkWitness := nut11.P2PKWitness{Signatures: signatures} + witness, err := json.Marshal(p2pkWitness) + if err != nil { + return nil, err + } + + proof.Witness = string(witness) + inputs[i] = proof + } + + return inputs, nil +} + +func AddSignaturesToOutputs( + outputs cashu.BlindedMessages, + signingKeys []*btcec.PrivateKey, +) (cashu.BlindedMessages, error) { + for i, output := range outputs { + msgToSign, err := hex.DecodeString(output.B_) + if err != nil { + return nil, err + } + hash := sha256.Sum256(msgToSign) + signatures := make([]string, len(signingKeys)) + + for j, key := range signingKeys { + signature, err := schnorr.Sign(key, hash[:]) + if err != nil { + return nil, err + } + sig := hex.EncodeToString(signature.Serialize()) + signatures[j] = sig + } + + p2pkWitness := nut11.P2PKWitness{Signatures: signatures} + witness, err := json.Marshal(p2pkWitness) + if err != nil { + return nil, err + } + output.Witness = string(witness) + outputs[i] = output + } + + return outputs, nil +} + func Fees(proofs cashu.Proofs, mint string) (uint, error) { keysetResponse, err := wallet.GetAllKeysets(mint) if err != nil { diff --git a/wallet/wallet.go b/wallet/wallet.go index 798132b..fa87c05 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -615,7 +615,6 @@ func (w *Wallet) swapToTrusted(token cashu.Token) (cashu.Proofs, error) { fees := uint64(w.fees(proofsToSwap, mint)) // if proofs are P2PK locked, sign appropriately - //if proofsToSwap[0].IsSecretP2PK() { if nut11.IsSecretP2PK(proofsToSwap[0]) { nut10secret, err := nut10.DeserializeSecret(proofsToSwap[0].Secret) if err != nil { @@ -1202,7 +1201,7 @@ func blindedMessagesFromLock(splitAmounts []uint64, keysetId string, lockPubkey return nil, nil, nil, err } - secret, err := nut11.P2PKSecret(pubkey) + secret, err := nut11.P2PKSecret(pubkey, nut11.P2PKTags{}) if err != nil { return nil, nil, nil, err }