From f6dd3ca830c60777cbd98cc0f5368c0d778083ee Mon Sep 17 00:00:00 2001 From: elnosh Date: Thu, 4 Jul 2024 08:46:47 -0500 Subject: [PATCH 1/3] wallet - lock ecash to a public key --- cashu/cashu.go | 28 ++++++ cashu/nuts/nut10/nut10.go | 27 ++++++ cashu/nuts/nut11/nut11.go | 37 ++++++++ cmd/nutw/nutw.go | 35 +++++++- wallet/p2pk.go | 40 +++++++++ wallet/wallet.go | 177 ++++++++++++++++++++++++++++++-------- 6 files changed, 307 insertions(+), 37 deletions(-) create mode 100644 cashu/nuts/nut10/nut10.go create mode 100644 cashu/nuts/nut11/nut11.go create mode 100644 wallet/p2pk.go diff --git a/cashu/cashu.go b/cashu/cashu.go index db5e9b0..a3669a5 100644 --- a/cashu/cashu.go +++ b/cashu/cashu.go @@ -9,6 +9,13 @@ import ( "fmt" ) +type SecretKind int + +const ( + Random SecretKind = iota + P2PK +) + // Cashu BlindedMessage. See https://github.com/cashubtc/nuts/blob/main/00.md#blindedmessage type BlindedMessage struct { Amount uint64 `json:"amount"` @@ -51,6 +58,27 @@ type Proof struct { Witness string `json:"witness,omitempty"` } +// TODO +func (p Proof) IsSecretP2PK() bool { + return false +} + +// TODO +func (p Proof) SecretType() SecretKind { + return Random +} + +func (kind SecretKind) String() string { + switch kind { + case Random: + return "random" + case P2PK: + return "P2PK" + default: + return "unknown" + } +} + type Proofs []Proof // Amount returns the total amount from diff --git a/cashu/nuts/nut10/nut10.go b/cashu/nuts/nut10/nut10.go new file mode 100644 index 0000000..8462cb9 --- /dev/null +++ b/cashu/nuts/nut10/nut10.go @@ -0,0 +1,27 @@ +package nut10 + +import ( + "encoding/json" + "fmt" + + "github.com/elnosh/gonuts/cashu" +) + +type WellKnownSecret struct { + Nonce string `json:"nonce"` + Data string `json:"data"` + Tags [][]string `json:"tags"` +} + +// SerializeSecret returns the json string to be put in the secret field of a proof +func SerializeSecret(kind cashu.SecretKind, secretData WellKnownSecret) (string, error) { + jsonSecret, err := json.Marshal(secretData) + if err != nil { + return "", err + } + + secretKind := kind.String() + secret := fmt.Sprintf("[\"%s\", %v]", secretKind, string(jsonSecret)) + + return secret, nil +} diff --git a/cashu/nuts/nut11/nut11.go b/cashu/nuts/nut11/nut11.go new file mode 100644 index 0000000..dfda656 --- /dev/null +++ b/cashu/nuts/nut11/nut11.go @@ -0,0 +1,37 @@ +package nut11 + +import ( + "crypto/rand" + "encoding/hex" + + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut10" +) + +type P2PKWitness struct { + Signatures []string `json:"signatures"` +} + +// P2PKSecret returns a secret with a spending condition +// that will lock ecash to a public key +func P2PKSecret(pubkey string) (string, error) { + // generate random nonce + nonceBytes := make([]byte, 32) + _, err := rand.Read(nonceBytes) + if err != nil { + return "", err + } + nonce := hex.EncodeToString(nonceBytes) + + secretData := nut10.WellKnownSecret{ + Nonce: nonce, + Data: pubkey, + } + + secret, err := nut10.SerializeSecret(cashu.P2PK, secretData) + if err != nil { + return "", err + } + + return secret, nil +} diff --git a/cmd/nutw/nutw.go b/cmd/nutw/nutw.go index 7899e2d..6460a73 100644 --- a/cmd/nutw/nutw.go +++ b/cmd/nutw/nutw.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "encoding/hex" "errors" "fmt" "log" @@ -12,6 +13,7 @@ import ( "strconv" "strings" + "github.com/btcsuite/btcd/btcec/v2" "github.com/elnosh/gonuts/cashu" "github.com/elnosh/gonuts/wallet" "github.com/joho/godotenv" @@ -259,9 +261,17 @@ func mintTokens(paymentRequest string) error { return nil } +const lockFlag = "lock" + var sendCmd = &cli.Command{ Name: "send", Before: setupWallet, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: lockFlag, + Usage: "generate ecash locked to a public key", + }, + }, Action: send, } @@ -278,9 +288,28 @@ func send(ctx *cli.Context) error { selectedMint := promptMintSelection("send") - token, err := nutw.Send(sendAmount, selectedMint) - if err != nil { - printErr(err) + var token *cashu.Token + // if lock flag is set, get ecash locked to the pubkey + if ctx.IsSet(lockFlag) { + lockpubkey := ctx.String(lockFlag) + lockbytes, err := hex.DecodeString(lockpubkey) + if err != nil { + printErr(err) + } + pubkey, err := btcec.ParsePubKey(lockbytes) + if err != nil { + printErr(err) + } + + token, err = nutw.SendToPubkey(sendAmount, selectedMint, pubkey) + if err != nil { + printErr(err) + } + } else { + token, err = nutw.Send(sendAmount, selectedMint) + if err != nil { + printErr(err) + } } fmt.Printf("%v\n", token.ToString()) diff --git a/wallet/p2pk.go b/wallet/p2pk.go new file mode 100644 index 0000000..37f4d70 --- /dev/null +++ b/wallet/p2pk.go @@ -0,0 +1,40 @@ +package wallet + +import ( + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/hdkeychain" +) + +// Derive key that wallet will use to receive locked ecash +func DeriveP2PK(key *hdkeychain.ExtendedKey) (*btcec.PrivateKey, error) { + // m/129372 + purpose, err := key.Derive(hdkeychain.HardenedKeyStart + 129372) + if err != nil { + return nil, err + } + + // m/129372'/0' + coinType, err := purpose.Derive(hdkeychain.HardenedKeyStart + 0) + if err != nil { + return nil, err + } + + // m/129372'/0'/1' + first, err := coinType.Derive(hdkeychain.HardenedKeyStart + 1) + if err != nil { + return nil, err + } + + // m/129372'/0'/1'/0 + extKey, err := first.Derive(0) + if err != nil { + return nil, err + } + + pk, err := extKey.ECPrivKey() + if err != nil { + return nil, err + } + + return pk, nil +} diff --git a/wallet/wallet.go b/wallet/wallet.go index b5db262..6685ea0 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -10,6 +10,7 @@ import ( "slices" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -20,6 +21,7 @@ import ( "github.com/elnosh/gonuts/cashu/nuts/nut05" "github.com/elnosh/gonuts/cashu/nuts/nut07" "github.com/elnosh/gonuts/cashu/nuts/nut09" + "github.com/elnosh/gonuts/cashu/nuts/nut11" "github.com/elnosh/gonuts/cashu/nuts/nut13" "github.com/elnosh/gonuts/crypto" "github.com/elnosh/gonuts/wallet/storage" @@ -37,6 +39,9 @@ type Wallet struct { db storage.DB masterKey *hdkeychain.ExtendedKey + // key to receive locked ecash + privateKey *btcec.PrivateKey + // default mint currentMint *walletMint // list of mints that have been trusted @@ -86,7 +91,12 @@ func LoadWallet(config Config) (*Wallet, error) { return nil, err } - wallet := &Wallet{db: db, masterKey: masterKey} + privateKey, err := DeriveP2PK(masterKey) + if err != nil { + return nil, err + } + + wallet := &Wallet{db: db, masterKey: masterKey, privateKey: privateKey} wallet.mints, err = wallet.getWalletMints() if err != nil { return nil, err @@ -383,7 +393,7 @@ func (w *Wallet) MintTokens(quoteId string) (cashu.Proofs, error) { // Send will return a cashu token with proofs for the given amount func (w *Wallet) Send(amount uint64, mintURL string) (*cashu.Token, error) { - proofsToSend, err := w.getProofsForAmount(amount, mintURL) + proofsToSend, err := w.getProofsForAmount(amount, mintURL, nil) if err != nil { return nil, err } @@ -392,6 +402,32 @@ func (w *Wallet) Send(amount uint64, mintURL string) (*cashu.Token, error) { return &token, nil } +// SendToPubkey returns a cashu token with proofs that are locked to +// the passed pubkey +func (w *Wallet) SendToPubkey( + amount uint64, + mintURL string, + pubkey *btcec.PublicKey, +) (*cashu.Token, error) { + // check first if mint supports P2PK NUT + mintInfo, err := GetMintInfo(mintURL) + if err != nil { + return nil, fmt.Errorf("error getting info from mint: %v", err) + } + nut11 := mintInfo.Nuts[11].(map[string]interface{}) + if nut11["supported"] != true { + return nil, errors.New("mint does not support Pay to Public Key") + } + + lockedProofs, err := w.getProofsForAmount(amount, mintURL, pubkey) + if err != nil { + return nil, err + } + + token := cashu.NewToken(lockedProofs, mintURL, "sat") + return &token, nil +} + // Receives Cashu token. If swap is true, it will swap the funds to the configured default mint. // If false, it will add the proofs from the mint and add that mint to the list of trusted mints. func (w *Wallet) Receive(token cashu.Token, swap bool) (uint64, error) { @@ -532,7 +568,7 @@ func (w *Wallet) Melt(invoice string, mint string) (*nut05.PostMeltQuoteBolt11Re } amountNeeded := meltQuoteResponse.Amount + meltQuoteResponse.FeeReserve - proofs, err := w.getProofsForAmount(amountNeeded, mint) + proofs, err := w.getProofsForAmount(amountNeeded, mint, nil) if err != nil { return nil, err } @@ -608,8 +644,9 @@ func (w *Wallet) getActiveProofsByMint(mintURL string) cashu.Proofs { } // getProofsForAmount will return proofs from mint that equal to given amount. +// if pubkeyLock is present it will generate proofs locked to the public key. // It returns error if wallet does not have enough proofs to fulfill amount -func (w *Wallet) getProofsForAmount(amount uint64, mintURL string) (cashu.Proofs, error) { +func (w *Wallet) getProofsForAmount(amount uint64, mintURL string, pubkeyLock *btcec.PublicKey) (cashu.Proofs, error) { selectedMint, ok := w.mints[mintURL] if !ok { return nil, ErrMintNotExist @@ -638,12 +675,16 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string) (cashu.Proofs addKeysetProofs(w.getInactiveProofsByMint(mintURL)) addKeysetProofs(w.getActiveProofsByMint(mintURL)) - // if proofs stored fulfill amount, delete them from db and return them - if currentProofsAmount == amount { - for _, proof := range selectedProofs { - w.db.DeleteProof(proof.Secret) + // only try selecting offline if secret is not specified + // if secret is specified, need to do swap first to create locked proofs + if pubkeyLock == nil { + // if proofs stored fulfill amount, delete them from db and return them + if currentProofsAmount == amount { + for _, proof := range selectedProofs { + w.db.DeleteProof(proof.Secret) + } + return selectedProofs, nil } - return selectedProofs, nil } var activeSatKeyset crypto.WalletKeyset @@ -652,18 +693,34 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string) (cashu.Proofs break } - counter := w.counterForKeyset(activeSatKeyset.Id) - - // blinded messages for send amount - send, secrets, rs, err := w.createBlindedMessages(amount, activeSatKeyset.Id, counter) - if err != nil { - return nil, err - } + var send cashu.BlindedMessages + var blindedMessages cashu.BlindedMessages + var secrets []string + var rs []*secp256k1.PrivateKey + var counter uint32 + // check here if lock is present and if so, use it to generate blinded messages + // instead of generating from counter + if pubkeyLock == nil { + counter = w.counterForKeyset(activeSatKeyset.Id) + var err error + // blinded messages for send amount from counter + send, secrets, rs, err = w.createBlindedMessages(amount, activeSatKeyset.Id, counter) + if err != nil { + return nil, err + } - counter += uint32(len(send)) + counter += uint32(len(send)) + } else { + // if pubkey to lock ecash is present, generate blinded messages + // with secrets locking the ecash + var err error + send, secrets, rs, err = blindedMessagesFromLock(amount, activeSatKeyset.Id, pubkeyLock) + if err != nil { + return nil, err + } - blindedMessages := make(cashu.BlindedMessages, len(send)) - copy(blindedMessages, send) + counter = w.counterForKeyset(activeSatKeyset.Id) + } // blinded messages for change amount change, changeSecrets, changeRs, err := w.createBlindedMessages(currentProofsAmount-amount, activeSatKeyset.Id, counter) @@ -671,25 +728,13 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string) (cashu.Proofs return nil, err } + blindedMessages = make(cashu.BlindedMessages, len(send)) + copy(blindedMessages, send) blindedMessages = append(blindedMessages, change...) secrets = append(secrets, changeSecrets...) rs = append(rs, changeRs...) - // sort messages, secrets and rs - for i := 0; i < len(blindedMessages)-1; i++ { - for j := i + 1; j < len(blindedMessages); j++ { - if blindedMessages[i].Amount > blindedMessages[j].Amount { - // Swap blinded messages - blindedMessages[i], blindedMessages[j] = blindedMessages[j], blindedMessages[i] - - // Swap secrets - secrets[i], secrets[j] = secrets[j], secrets[i] - - // Swap rs - rs[i], rs[j] = rs[j], rs[i] - } - } - } + sortBlindedMessages(blindedMessages, secrets, rs) swapRequest := nut03.PostSwapRequest{Inputs: selectedProofs, Outputs: blindedMessages} swapResponse, err := PostSwap(selectedMint.mintURL, swapRequest) @@ -786,6 +831,64 @@ func blindMessage(path *hdkeychain.ExtendedKey, counter uint32) ( return B_, secret, r, nil } +func blindedMessagesFromLock(amount uint64, keysetId string, lockPubkey *btcec.PublicKey) ( + cashu.BlindedMessages, + []string, + []*secp256k1.PrivateKey, + error, +) { + serialized := lockPubkey.SerializeCompressed() + pubkey := hex.EncodeToString(serialized) + + splitAmounts := cashu.AmountSplit(amount) + splitLen := len(splitAmounts) + + blindedMessages := make(cashu.BlindedMessages, splitLen) + secrets := make([]string, splitLen) + rs := make([]*secp256k1.PrivateKey, splitLen) + + for i, amt := range splitAmounts { + r, err := secp256k1.GeneratePrivateKey() + if err != nil { + return nil, nil, nil, err + } + + secret, err := nut11.P2PKSecret(pubkey) + if err != nil { + return nil, nil, nil, err + } + + B_, r, err := crypto.BlindMessage(secret, r) + if err != nil { + return nil, nil, nil, err + } + + blindedMessages[i] = newBlindedMessage(keysetId, amt, B_) + secrets[i] = string(secret) + rs[i] = r + } + + return blindedMessages, secrets, rs, nil +} + +func sortBlindedMessages(blindedMessages cashu.BlindedMessages, secrets []string, rs []*secp256k1.PrivateKey) { + // sort messages, secrets and rs + for i := 0; i < len(blindedMessages)-1; i++ { + for j := i + 1; j < len(blindedMessages); j++ { + if blindedMessages[i].Amount > blindedMessages[j].Amount { + // Swap blinded messages + blindedMessages[i], blindedMessages[j] = blindedMessages[j], blindedMessages[i] + + // Swap secrets + secrets[i], secrets[j] = secrets[j], secrets[i] + + // Swap rs + rs[i], rs[j] = rs[j], rs[i] + } + } + } +} + // constructProofs unblinds the blindedSignatures and returns the proofs func constructProofs( blindedSignatures cashu.BlindedSignatures, @@ -961,6 +1064,12 @@ func (w *Wallet) TrustedMints() []string { return trustedMints } +// GetReceivePubkey retrieves public key to which +// the wallet can receive locked ecash +func (w *Wallet) GetReceivePubkey() *btcec.PublicKey { + return w.privateKey.PubKey() +} + func (w *Wallet) Mnemonic() string { return w.db.GetMnemonic() } From b9c1d5646fc6219b2f7e32ba525aa04e39809ec2 Mon Sep 17 00:00:00 2001 From: elnosh Date: Fri, 5 Jul 2024 11:40:50 -0500 Subject: [PATCH 2/3] wallet - receive locked ecash --- cashu/cashu.go | 74 +++++++++--- cashu/nuts/nut10/nut10.go | 28 ++++- cashu/nuts/nut11/nut11.go | 92 +++++++++++++++ cmd/nutw/nutw.go | 17 +++ wallet/wallet.go | 239 +++++++++++++++++++++++++++----------- wallet/wallet_test.go | 3 +- 6 files changed, 364 insertions(+), 89 deletions(-) diff --git a/cashu/cashu.go b/cashu/cashu.go index a3669a5..ed5fc34 100644 --- a/cashu/cashu.go +++ b/cashu/cashu.go @@ -4,9 +4,12 @@ package cashu import ( "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" + + "github.com/decred/dcrd/dcrec/secp256k1/v4" ) type SecretKind int @@ -18,15 +21,35 @@ const ( // Cashu BlindedMessage. See https://github.com/cashubtc/nuts/blob/main/00.md#blindedmessage type BlindedMessage struct { - Amount uint64 `json:"amount"` - B_ string `json:"B_"` - Id string `json:"id"` - - // including Witness field for now to avoid throwing error when parsing json - // from clients that include this field even when mint does not support it. + Amount uint64 `json:"amount"` + B_ string `json:"B_"` + Id string `json:"id"` Witness string `json:"witness,omitempty"` } +func NewBlindedMessage(id string, amount uint64, B_ *secp256k1.PublicKey) BlindedMessage { + B_str := hex.EncodeToString(B_.SerializeCompressed()) + return BlindedMessage{Amount: amount, B_: B_str, Id: id} +} + +func SortBlindedMessages(blindedMessages BlindedMessages, secrets []string, rs []*secp256k1.PrivateKey) { + // sort messages, secrets and rs + for i := 0; i < len(blindedMessages)-1; i++ { + for j := i + 1; j < len(blindedMessages); j++ { + if blindedMessages[i].Amount > blindedMessages[j].Amount { + // Swap blinded messages + blindedMessages[i], blindedMessages[j] = blindedMessages[j], blindedMessages[i] + + // Swap secrets + secrets[i], secrets[j] = secrets[j], secrets[i] + + // Swap rs + rs[i], rs[j] = rs[j], rs[i] + } + } + } +} + type BlindedMessages []BlindedMessage func (bm BlindedMessages) Amount() uint64 { @@ -48,34 +71,47 @@ type BlindedSignatures []BlindedSignature // Cashu Proof. See https://github.com/cashubtc/nuts/blob/main/00.md#proof type Proof struct { - Amount uint64 `json:"amount"` - Id string `json:"id"` - Secret string `json:"secret"` - C string `json:"C"` - - // including Witness field for now to avoid throwing error when parsing json - // from clients that include this field even when mint does not support it. + Amount uint64 `json:"amount"` + Id string `json:"id"` + Secret string `json:"secret"` + C string `json:"C"` Witness string `json:"witness,omitempty"` } -// TODO func (p Proof) IsSecretP2PK() bool { - return false + return p.SecretType() == P2PK } -// TODO func (p Proof) SecretType() SecretKind { + var rawJsonSecret []json.RawMessage + // if not valid json, assume it is random secret + if err := json.Unmarshal([]byte(p.Secret), &rawJsonSecret); err != nil { + return Random + } + + // Well-known secret should have a length of at least 2 + if len(rawJsonSecret) < 2 { + return Random + } + + var kind string + if err := json.Unmarshal(rawJsonSecret[0], &kind); err != nil { + return Random + } + + if kind == "P2PK" { + return P2PK + } + return Random } func (kind SecretKind) String() string { switch kind { - case Random: - return "random" case P2PK: return "P2PK" default: - return "unknown" + return "random" } } diff --git a/cashu/nuts/nut10/nut10.go b/cashu/nuts/nut10/nut10.go index 8462cb9..1e73b30 100644 --- a/cashu/nuts/nut10/nut10.go +++ b/cashu/nuts/nut10/nut10.go @@ -2,6 +2,7 @@ package nut10 import ( "encoding/json" + "errors" "fmt" "github.com/elnosh/gonuts/cashu" @@ -22,6 +23,31 @@ func SerializeSecret(kind cashu.SecretKind, secretData WellKnownSecret) (string, secretKind := kind.String() secret := fmt.Sprintf("[\"%s\", %v]", secretKind, string(jsonSecret)) - return secret, nil } + +// DeserializeSecret returns Well-known secret struct. +// It returns error if it's not valid according to NUT-10 +func DeserializeSecret(secret string) (WellKnownSecret, error) { + var rawJsonSecret []json.RawMessage + if err := json.Unmarshal([]byte(secret), &rawJsonSecret); err != nil { + return WellKnownSecret{}, err + } + + // Well-known secret should have a length of at least 2 + if len(rawJsonSecret) < 2 { + return WellKnownSecret{}, errors.New("invalid secret: length < 2") + } + + var kind string + if err := json.Unmarshal(rawJsonSecret[0], &kind); err != nil { + return WellKnownSecret{}, errors.New("invalid kind for secret") + } + + var secretData WellKnownSecret + if err := json.Unmarshal(rawJsonSecret[1], &secretData); err != nil { + return WellKnownSecret{}, fmt.Errorf("invalid secret: %v", err) + } + + return secretData, nil +} diff --git a/cashu/nuts/nut11/nut11.go b/cashu/nuts/nut11/nut11.go index dfda656..889ea87 100644 --- a/cashu/nuts/nut11/nut11.go +++ b/cashu/nuts/nut11/nut11.go @@ -2,8 +2,14 @@ package nut11 import ( "crypto/rand" + "crypto/sha256" "encoding/hex" + "encoding/json" + "reflect" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/elnosh/gonuts/cashu" "github.com/elnosh/gonuts/cashu/nuts/nut10" ) @@ -35,3 +41,89 @@ func P2PKSecret(pubkey string) (string, error) { return secret, nil } + +func AddSignatureToInputs(inputs cashu.Proofs, signingKey *btcec.PrivateKey) (cashu.Proofs, error) { + for i, proof := range inputs { + hash := sha256.Sum256([]byte(proof.Secret)) + signature, err := schnorr.Sign(signingKey, hash[:]) + if err != nil { + return nil, err + } + signatureBytes := signature.Serialize() + + p2pkWitness := P2PKWitness{ + Signatures: []string{hex.EncodeToString(signatureBytes)}, + } + + witness, err := json.Marshal(p2pkWitness) + if err != nil { + return nil, err + } + proof.Witness = string(witness) + inputs[i] = proof + } + + return inputs, nil +} + +func AddSignatureToOutputs( + outputs cashu.BlindedMessages, + signingKey *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) + signature, err := schnorr.Sign(signingKey, hash[:]) + if err != nil { + return nil, err + } + signatureBytes := signature.Serialize() + + p2pkWitness := P2PKWitness{ + Signatures: []string{hex.EncodeToString(signatureBytes)}, + } + + witness, err := json.Marshal(p2pkWitness) + if err != nil { + return nil, err + } + output.Witness = string(witness) + outputs[i] = output + } + + return outputs, nil +} + +func IsSigAll(secret nut10.WellKnownSecret) bool { + for _, tag := range secret.Tags { + if len(tag) == 2 { + if tag[0] == "sigflag" && tag[1] == "SIG_ALL" { + return true + } + } + } + + return false +} + +func CanSign(secret nut10.WellKnownSecret, key *btcec.PrivateKey) bool { + secretData, err := hex.DecodeString(secret.Data) + if err != nil { + return false + } + + publicKey, err := secp256k1.ParsePubKey(secretData) + if err != nil { + return false + } + + if reflect.DeepEqual(publicKey.SerializeCompressed(), key.PubKey().SerializeCompressed()) { + return true + } + + return false +} diff --git a/cmd/nutw/nutw.go b/cmd/nutw/nutw.go index 6460a73..61fa11d 100644 --- a/cmd/nutw/nutw.go +++ b/cmd/nutw/nutw.go @@ -103,6 +103,7 @@ func main() { sendCmd, receiveCmd, payCmd, + p2pkLockCmd, mnemonicCmd, restoreCmd, }, @@ -345,6 +346,22 @@ func pay(ctx *cli.Context) error { return nil } +var p2pkLockCmd = &cli.Command{ + Name: "p2pk-lock", + Before: setupWallet, + Action: p2pkLock, +} + +func p2pkLock(ctx *cli.Context) error { + lockpubkey := nutw.GetReceivePubkey() + pubkey := hex.EncodeToString(lockpubkey.SerializeCompressed()) + + fmt.Printf("Pay to Public Key (P2PK) lock: %v\n\n", pubkey) + fmt.Println("You can unlock ecash locked to this public key") + + return nil +} + var mnemonicCmd = &cli.Command{ Name: "mnemonic", Before: setupWallet, diff --git a/wallet/wallet.go b/wallet/wallet.go index 6685ea0..bad1b19 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1,6 +1,7 @@ package wallet import ( + "crypto/rand" "encoding/hex" "errors" "fmt" @@ -21,6 +22,7 @@ import ( "github.com/elnosh/gonuts/cashu/nuts/nut05" "github.com/elnosh/gonuts/cashu/nuts/nut07" "github.com/elnosh/gonuts/cashu/nuts/nut09" + "github.com/elnosh/gonuts/cashu/nuts/nut10" "github.com/elnosh/gonuts/cashu/nuts/nut11" "github.com/elnosh/gonuts/cashu/nuts/nut13" "github.com/elnosh/gonuts/crypto" @@ -350,7 +352,7 @@ func (w *Wallet) MintTokens(quoteId string) (cashu.Proofs, error) { // get counter for keyset counter := w.counterForKeyset(activeKeyset.Id) - blindedMessages, secrets, rs, err := w.createBlindedMessages(invoice.QuoteAmount, activeKeyset.Id, counter) + blindedMessages, secrets, rs, err := w.createBlindedMessages(invoice.QuoteAmount, activeKeyset.Id, &counter) if err != nil { return nil, fmt.Errorf("error creating blinded messages: %v", err) } @@ -430,8 +432,8 @@ func (w *Wallet) SendToPubkey( // Receives Cashu token. If swap is true, it will swap the funds to the configured default mint. // If false, it will add the proofs from the mint and add that mint to the list of trusted mints. -func (w *Wallet) Receive(token cashu.Token, swap bool) (uint64, error) { - if swap { +func (w *Wallet) Receive(token cashu.Token, swapToTrusted bool) (uint64, error) { + if swapToTrusted { trustedMintProofs, err := w.swapToTrusted(token) if err != nil { return 0, fmt.Errorf("error swapping token to trusted mint: %v", err) @@ -445,50 +447,112 @@ func (w *Wallet) Receive(token cashu.Token, swap bool) (uint64, error) { tokenMintURL := token.Token[0].Mint // only add mint if not previously trusted - walletMint, ok := w.mints[tokenMintURL] + _, ok := w.mints[tokenMintURL] if !ok { - mint, err := w.addMint(tokenMintURL) + _, err := w.addMint(tokenMintURL) if err != nil { return 0, err } - walletMint = *mint } - var activeSatKeyset crypto.WalletKeyset - for _, k := range walletMint.activeKeysets { + proofs, err := w.swap(proofsToSwap, tokenMintURL) + if err != nil { + return 0, err + } + + w.saveProofs(proofs) + + return proofs.Amount(), nil + } +} + +// swap to be used when receiving +func (w *Wallet) swap(proofsToSwap cashu.Proofs, mintURL string) (cashu.Proofs, error) { + var nut10secret nut10.WellKnownSecret + // if P2PK, add signature to Witness in the proofs + if proofsToSwap[0].IsSecretP2PK() { + var err error + nut10secret, err = nut10.DeserializeSecret(proofsToSwap[0].Secret) + if err != nil { + return nil, err + } + // check that public key in data is one wallet can sign for + if !nut11.CanSign(nut10secret, w.privateKey) { + return nil, fmt.Errorf("cannot sign locked proofs") + } + + proofsToSwap, err = nut11.AddSignatureToInputs(proofsToSwap, w.privateKey) + if err != nil { + return nil, fmt.Errorf("error signing inputs: %v", err) + } + } + + var activeSatKeyset crypto.WalletKeyset + var outputs cashu.BlindedMessages + var secrets []string + var rs []*secp256k1.PrivateKey + var err error + walletMint, trustedMint := w.mints[mintURL] + if !trustedMint { + activeKeysets, err := GetMintActiveKeysets(mintURL) + if err != nil { + return nil, err + } + for _, k := range activeKeysets { activeSatKeyset = k break } - counter := w.counterForKeyset(activeSatKeyset.Id) // create blinded messages - outputs, secrets, rs, err := w.createBlindedMessages(token.TotalAmount(), activeSatKeyset.Id, counter) + outputs, secrets, rs, err = w.createBlindedMessages(proofsToSwap.Amount(), activeSatKeyset.Id, nil) if err != nil { - return 0, fmt.Errorf("createBlindedMessages: %v", err) + return nil, fmt.Errorf("createBlindedMessages: %v", err) + } + } else { + for _, k := range walletMint.activeKeysets { + activeSatKeyset = k + break } - // make swap request to mint - swapRequest := nut03.PostSwapRequest{Inputs: proofsToSwap, Outputs: outputs} - swapResponse, err := PostSwap(tokenMintURL, swapRequest) + counter := w.counterForKeyset(activeSatKeyset.Id) + + // create blinded messages + outputs, secrets, rs, err = w.createBlindedMessages(proofsToSwap.Amount(), activeSatKeyset.Id, &counter) if err != nil { - return 0, err + return nil, fmt.Errorf("createBlindedMessages: %v", err) } + } - // unblind signatures to get proofs and save them to db - proofs, err := constructProofs(swapResponse.Signatures, secrets, rs, &activeSatKeyset) + // if P2PK locked ecash has `SIG_ALL` flag, sign outputs + if proofsToSwap[0].IsSecretP2PK() && nut11.IsSigAll(nut10secret) { + outputs, err = nut11.AddSignatureToOutputs(outputs, w.privateKey) if err != nil { - return 0, fmt.Errorf("wallet.ConstructProofs: %v", err) + return nil, fmt.Errorf("error signing outputs: %v", err) } + } - w.saveProofs(proofs) + // make swap request to mint + swapRequest := nut03.PostSwapRequest{Inputs: proofsToSwap, Outputs: outputs} + swapResponse, err := PostSwap(mintURL, swapRequest) + if err != nil { + return nil, err + } + // unblind signatures to get proofs and save them to db + proofs, err := constructProofs(swapResponse.Signatures, secrets, rs, &activeSatKeyset) + if err != nil { + return nil, fmt.Errorf("wallet.ConstructProofs: %v", err) + } + + // only increment the counter if mint was from trusted list + if trustedMint { err = w.incrementKeysetCounter(activeSatKeyset.Id, uint32(len(outputs))) if err != nil { - return 0, fmt.Errorf("error incrementing keyset counter: %v", err) + return nil, fmt.Errorf("error incrementing keyset counter: %v", err) } - - return proofs.Amount(), nil } + + return proofs, nil } // swapToTrusted will swap the proofs from mint in the token @@ -534,6 +598,32 @@ func (w *Wallet) swapToTrusted(token cashu.Token) (cashu.Proofs, error) { } } + // if proofs are P2PK locked, sign appropriately + if proofsToSwap[0].IsSecretP2PK() { + nut10secret, err := nut10.DeserializeSecret(proofsToSwap[0].Secret) + if err != nil { + return nil, err + } + + // if sig all, swap them first and then melt + if nut11.IsSigAll(nut10secret) { + proofsToSwap, err = w.swap(proofsToSwap, tokenMintURL) + if err != nil { + return nil, err + } + } else { // if not sig all, can just sign inputs and no need to do a swap first + // check that public key in data is one wallet can sign for + if !nut11.CanSign(nut10secret, w.privateKey) { + return nil, fmt.Errorf("cannot sign locked proofs") + } + + proofsToSwap, err = nut11.AddSignatureToInputs(proofsToSwap, w.privateKey) + if err != nil { + return nil, fmt.Errorf("error signing inputs: %v", err) + } + } + } + // request untrusted mint to pay invoice generated from trusted mint meltBolt11Request := nut05.PostMeltBolt11Request{Quote: meltQuoteResponse.Quote, Inputs: proofsToSwap} meltBolt11Response, err := PostMeltBolt11(tokenMintURL, meltBolt11Request) @@ -675,8 +765,8 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string, pubkeyLock *b addKeysetProofs(w.getInactiveProofsByMint(mintURL)) addKeysetProofs(w.getActiveProofsByMint(mintURL)) - // only try selecting offline if secret is not specified - // if secret is specified, need to do swap first to create locked proofs + // only try selecting offline if lock is not specified + // if lock is specified, need to do swap first to create locked proofs if pubkeyLock == nil { // if proofs stored fulfill amount, delete them from db and return them if currentProofsAmount == amount { @@ -697,19 +787,18 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string, pubkeyLock *b var blindedMessages cashu.BlindedMessages var secrets []string var rs []*secp256k1.PrivateKey - var counter uint32 + var counter, incrementCounterBy uint32 // check here if lock is present and if so, use it to generate blinded messages // instead of generating from counter if pubkeyLock == nil { counter = w.counterForKeyset(activeSatKeyset.Id) var err error // blinded messages for send amount from counter - send, secrets, rs, err = w.createBlindedMessages(amount, activeSatKeyset.Id, counter) + send, secrets, rs, err = w.createBlindedMessages(amount, activeSatKeyset.Id, &counter) if err != nil { return nil, err } - - counter += uint32(len(send)) + incrementCounterBy += uint32(len(send)) } else { // if pubkey to lock ecash is present, generate blinded messages // with secrets locking the ecash @@ -723,10 +812,11 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string, pubkeyLock *b } // blinded messages for change amount - change, changeSecrets, changeRs, err := w.createBlindedMessages(currentProofsAmount-amount, activeSatKeyset.Id, counter) + change, changeSecrets, changeRs, err := w.createBlindedMessages(currentProofsAmount-amount, activeSatKeyset.Id, &counter) if err != nil { return nil, err } + incrementCounterBy += uint32(len(change)) blindedMessages = make(cashu.BlindedMessages, len(send)) copy(blindedMessages, send) @@ -734,7 +824,7 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string, pubkeyLock *b secrets = append(secrets, changeSecrets...) rs = append(rs, changeRs...) - sortBlindedMessages(blindedMessages, secrets, rs) + cashu.SortBlindedMessages(blindedMessages, secrets, rs) swapRequest := nut03.PostSwapRequest{Inputs: selectedProofs, Outputs: blindedMessages} swapResponse, err := PostSwap(selectedMint.mintURL, swapRequest) @@ -765,7 +855,7 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string, pubkeyLock *b // remaining proofs are change proofs to save to db w.saveProofs(proofs) - err = w.incrementKeysetCounter(activeSatKeyset.Id, uint32(len(blindedMessages))) + err = w.incrementKeysetCounter(activeSatKeyset.Id, incrementCounterBy) if err != nil { return nil, fmt.Errorf("error incrementing keyset counter: %v", err) } @@ -774,7 +864,13 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string, pubkeyLock *b } // returns Blinded messages, secrets - [][]byte, and list of r -func (w *Wallet) createBlindedMessages(amount uint64, keysetId string, counter uint32) (cashu.BlindedMessages, []string, []*secp256k1.PrivateKey, error) { +// if counter is nil, it generates random secrets +// if counter is non-nil, it will generate secrets deterministically +func (w *Wallet) createBlindedMessages( + amount uint64, + keysetId string, + counter *uint32, +) (cashu.BlindedMessages, []string, []*secp256k1.PrivateKey, error) { splitAmounts := cashu.AmountSplit(amount) splitLen := len(splitAmounts) @@ -788,47 +884,66 @@ func (w *Wallet) createBlindedMessages(amount uint64, keysetId string, counter u } for i, amt := range splitAmounts { - B_, secret, r, err := blindMessage(keysetDerivationPath, counter) + var secret string + var r *secp256k1.PrivateKey + if counter == nil { + secret, r, err = generateRandomSecret() + if err != nil { + return nil, nil, nil, err + } + } else { + secret, r, err = generateDeterministicSecret(keysetDerivationPath, *counter) + if err != nil { + return nil, nil, nil, err + } + *counter++ + } + + B_, r, err := crypto.BlindMessage(secret, r) if err != nil { return nil, nil, nil, err } - blindedMessages[i] = newBlindedMessage(keysetId, amt, B_) + blindedMessages[i] = cashu.NewBlindedMessage(keysetId, amt, B_) secrets[i] = secret rs[i] = r - counter++ } return blindedMessages, secrets, rs, nil } -func newBlindedMessage(id string, amount uint64, B_ *secp256k1.PublicKey) cashu.BlindedMessage { - B_str := hex.EncodeToString(B_.SerializeCompressed()) - return cashu.BlindedMessage{Amount: amount, B_: B_str, Id: id} +func generateRandomSecret() (string, *secp256k1.PrivateKey, error) { + r, err := secp256k1.GeneratePrivateKey() + if err != nil { + return "", nil, err + } + + secretBytes := make([]byte, 32) + _, err = rand.Read(secretBytes) + if err != nil { + return "", nil, err + } + secret := hex.EncodeToString(secretBytes) + + return secret, r, nil } -func blindMessage(path *hdkeychain.ExtendedKey, counter uint32) ( - *secp256k1.PublicKey, +func generateDeterministicSecret(path *hdkeychain.ExtendedKey, counter uint32) ( string, *secp256k1.PrivateKey, error, ) { r, err := nut13.DeriveBlindingFactor(path, counter) if err != nil { - return nil, "", nil, err + return "", nil, err } secret, err := nut13.DeriveSecret(path, counter) if err != nil { - return nil, "", nil, err - } - - B_, r, err := crypto.BlindMessage(secret, r) - if err != nil { - return nil, "", nil, err + return "", nil, err } - return B_, secret, r, nil + return secret, r, nil } func blindedMessagesFromLock(amount uint64, keysetId string, lockPubkey *btcec.PublicKey) ( @@ -863,7 +978,7 @@ func blindedMessagesFromLock(amount uint64, keysetId string, lockPubkey *btcec.P return nil, nil, nil, err } - blindedMessages[i] = newBlindedMessage(keysetId, amt, B_) + blindedMessages[i] = cashu.NewBlindedMessage(keysetId, amt, B_) secrets[i] = string(secret) rs[i] = r } @@ -871,24 +986,6 @@ func blindedMessagesFromLock(amount uint64, keysetId string, lockPubkey *btcec.P return blindedMessages, secrets, rs, nil } -func sortBlindedMessages(blindedMessages cashu.BlindedMessages, secrets []string, rs []*secp256k1.PrivateKey) { - // sort messages, secrets and rs - for i := 0; i < len(blindedMessages)-1; i++ { - for j := i + 1; j < len(blindedMessages); j++ { - if blindedMessages[i].Amount > blindedMessages[j].Amount { - // Swap blinded messages - blindedMessages[i], blindedMessages[j] = blindedMessages[j], blindedMessages[i] - - // Swap secrets - secrets[i], secrets[j] = secrets[j], secrets[i] - - // Swap rs - rs[i], rs[j] = rs[j], rs[i] - } - } - } -} - // constructProofs unblinds the blindedSignatures and returns the proofs func constructProofs( blindedSignatures cashu.BlindedSignatures, @@ -957,6 +1054,11 @@ func (w *Wallet) counterForKeyset(keysetId string) uint32 { } // get active sat keyset for current mint +// TODO:refactor this method to take a mint URL +// if the mint url passed is not part of w.mints, call /v1/keys +// if mint is in w.mints, call /v1/keysets and check whether active keyset has changed +// if it has, get new active by calling /v1/keys +// if active keyset hasn't changed, return already stored func (w *Wallet) GetActiveSatKeyset() crypto.WalletKeyset { var activeKeyset crypto.WalletKeyset for _, keyset := range w.currentMint.activeKeysets { @@ -1168,7 +1270,8 @@ func Restore(walletPath, mnemonic string, mintsToRestore []string) (cashu.Proofs // create batch of 100 blinded messages for i := 0; i < 100; i++ { - B_, secret, r, err := blindMessage(keysetDerivationPath, counter) + secret, r, err := generateDeterministicSecret(keysetDerivationPath, counter) + B_, r, err := crypto.BlindMessage(secret, r) if err != nil { return nil, err } diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 991b2ce..9da0f26 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -37,7 +37,8 @@ func TestCreateBlindedMessages(t *testing.T) { } for _, test := range tests { - blindedMessages, _, _, _ := test.wallet.createBlindedMessages(test.amount, test.keyset.Id, 0) + var counter uint32 = 0 + blindedMessages, _, _, _ := test.wallet.createBlindedMessages(test.amount, test.keyset.Id, &counter) amount := blindedMessages.Amount() if amount != test.amount { t.Errorf("expected '%v' but got '%v' instead", test.amount, amount) From a75d5a69ba01bd9a14a2d3785ab07bc22c8d070d Mon Sep 17 00:00:00 2001 From: elnosh Date: Mon, 8 Jul 2024 14:13:49 -0500 Subject: [PATCH 3/3] tests P2PK nut --- cashu/cashu_test.go | 38 +++++++++++++ cashu/nuts/nut10/nut10_test.go | 54 ++++++++++++++++++ cashu/nuts/nut11/nut11_test.go | 84 ++++++++++++++++++++++++++++ wallet/wallet.go | 52 ++++++++---------- wallet/wallet_integration_test.go | 91 ++++++++++++++++++++++++++++++- 5 files changed, 288 insertions(+), 31 deletions(-) create mode 100644 cashu/nuts/nut10/nut10_test.go create mode 100644 cashu/nuts/nut11/nut11_test.go diff --git a/cashu/cashu_test.go b/cashu/cashu_test.go index 11d2c09..b020d5d 100644 --- a/cashu/cashu_test.go +++ b/cashu/cashu_test.go @@ -113,3 +113,41 @@ func TestTokenToString(t *testing.T) { } } } + +func TestSecretType(t *testing.T) { + tests := []struct { + proof Proof + expectedKind SecretKind + expectedIsP2PK bool + }{ + { + proof: Proof{Secret: `["P2PK", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[["sigflag","SIG_ALL"]]}]`}, + expectedKind: P2PK, + expectedIsP2PK: true, + }, + + { + proof: Proof{Secret: `["DIFFERENT", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[]}]`}, + expectedKind: Random, + expectedIsP2PK: false, + }, + + { + proof: Proof{Secret: `someranadomsecret`}, + expectedKind: Random, + expectedIsP2PK: false, + }, + } + + for _, test := range tests { + kind := test.proof.SecretType() + if kind != test.expectedKind { + t.Fatalf("expected '%v' but got '%v' instead", test.expectedKind.String(), kind.String()) + } + + isP2PK := test.proof.IsSecretP2PK() + if isP2PK != test.expectedIsP2PK { + t.Fatalf("expected '%v' but got '%v' instead", test.expectedIsP2PK, isP2PK) + } + } +} diff --git a/cashu/nuts/nut10/nut10_test.go b/cashu/nuts/nut10/nut10_test.go new file mode 100644 index 0000000..61e93e2 --- /dev/null +++ b/cashu/nuts/nut10/nut10_test.go @@ -0,0 +1,54 @@ +package nut10 + +import ( + "reflect" + "testing" + + "github.com/elnosh/gonuts/cashu" +) + +func TestSerializeSecret(t *testing.T) { + secretData := WellKnownSecret{ + Nonce: "da62796403af76c80cd6ce9153ed3746", + Data: "033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e", + Tags: [][]string{ + {"sigflag", "SIG_ALL"}, + }, + } + + serialized, err := SerializeSecret(cashu.P2PK, secretData) + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + expected := `["P2PK", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[["sigflag","SIG_ALL"]]}]` + + if serialized != expected { + t.Fatalf("expected secret:\n%v\n\n but got:\n%v", expected, serialized) + } +} + +func TestDeserializeSecret(t *testing.T) { + secret := `["P2PK", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[["sigflag","SIG_ALL"]]}]` + secretData, err := DeserializeSecret(secret) + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + expectedNonce := "da62796403af76c80cd6ce9153ed3746" + if secretData.Nonce != expectedNonce { + t.Fatalf("expected nonce '%v' but got '%v' instead", expectedNonce, secretData.Nonce) + } + + expectedData := "033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e" + if secretData.Data != expectedData { + t.Fatalf("expected data '%v' but got '%v' instead", expectedData, secretData.Data) + } + + expectedTags := [][]string{ + {"sigflag", "SIG_ALL"}, + } + if !reflect.DeepEqual(secretData.Tags, expectedTags) { + t.Fatalf("expected tags '%v' but got '%v' instead", expectedTags, secretData.Tags) + } +} diff --git a/cashu/nuts/nut11/nut11_test.go b/cashu/nuts/nut11/nut11_test.go new file mode 100644 index 0000000..c74bc59 --- /dev/null +++ b/cashu/nuts/nut11/nut11_test.go @@ -0,0 +1,84 @@ +package nut11 + +import ( + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/elnosh/gonuts/cashu/nuts/nut10" +) + +func TestIsSigAll(t *testing.T) { + tests := []struct { + p2pkSecretData nut10.WellKnownSecret + expected bool + }{ + { + p2pkSecretData: nut10.WellKnownSecret{ + Tags: [][]string{}, + }, + expected: false, + }, + { + p2pkSecretData: nut10.WellKnownSecret{ + Tags: [][]string{{"sigflag", "SIG_INPUTS"}}, + }, + expected: false, + }, + { + p2pkSecretData: nut10.WellKnownSecret{ + Tags: [][]string{ + {"locktime", "882912379"}, + {"refund", "refundkey"}, + {"sigflag", "SIG_ALL"}, + }, + }, + expected: true, + }, + } + + for _, test := range tests { + result := IsSigAll(test.p2pkSecretData) + if result != test.expected { + t.Fatalf("expected '%v' but got '%v' instead", test.expected, result) + } + } +} + +func TestCanSign(t *testing.T) { + privateKey, _ := btcec.NewPrivateKey() + publicKey := hex.EncodeToString(privateKey.PubKey().SerializeCompressed()) + + tests := []struct { + p2pkSecretData nut10.WellKnownSecret + expected bool + }{ + { + p2pkSecretData: nut10.WellKnownSecret{ + Data: publicKey, + }, + expected: true, + }, + + { + p2pkSecretData: nut10.WellKnownSecret{ + Data: "somerandomkey", + }, + expected: false, + }, + + { + p2pkSecretData: nut10.WellKnownSecret{ + Data: "sdjflksjdflsdjfd", + }, + expected: false, + }, + } + + for _, test := range tests { + result := CanSign(test.p2pkSecretData, privateKey) + if result != test.expected { + t.Fatalf("expected '%v' but got '%v' instead", test.expected, result) + } + } +} diff --git a/wallet/wallet.go b/wallet/wallet.go index bad1b19..a788f27 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -487,40 +487,33 @@ func (w *Wallet) swap(proofsToSwap cashu.Proofs, mintURL string) (cashu.Proofs, } } - var activeSatKeyset crypto.WalletKeyset - var outputs cashu.BlindedMessages - var secrets []string - var rs []*secp256k1.PrivateKey - var err error + var activeKeysets map[string]crypto.WalletKeyset walletMint, trustedMint := w.mints[mintURL] if !trustedMint { - activeKeysets, err := GetMintActiveKeysets(mintURL) + // get keys if mint not trusted + var err error + activeKeysets, err = GetMintActiveKeysets(mintURL) if err != nil { return nil, err } - for _, k := range activeKeysets { - activeSatKeyset = k - break - } - - // create blinded messages - outputs, secrets, rs, err = w.createBlindedMessages(proofsToSwap.Amount(), activeSatKeyset.Id, nil) - if err != nil { - return nil, fmt.Errorf("createBlindedMessages: %v", err) - } } else { - for _, k := range walletMint.activeKeysets { - activeSatKeyset = k - break - } + activeKeysets = walletMint.activeKeysets + } - counter := w.counterForKeyset(activeSatKeyset.Id) + var activeSatKeyset crypto.WalletKeyset + for _, k := range activeKeysets { + activeSatKeyset = k + break + } + var counter *uint32 = nil + if trustedMint { + keysetCounter := w.counterForKeyset(activeSatKeyset.Id) + counter = &keysetCounter + } - // create blinded messages - outputs, secrets, rs, err = w.createBlindedMessages(proofsToSwap.Amount(), activeSatKeyset.Id, &counter) - if err != nil { - return nil, fmt.Errorf("createBlindedMessages: %v", err) - } + outputs, secrets, rs, err := w.createBlindedMessages(proofsToSwap.Amount(), activeSatKeyset.Id, counter) + if err != nil { + return nil, fmt.Errorf("createBlindedMessages: %v", err) } // if P2PK locked ecash has `SIG_ALL` flag, sign outputs @@ -611,8 +604,8 @@ func (w *Wallet) swapToTrusted(token cashu.Token) (cashu.Proofs, error) { if err != nil { return nil, err } - } else { // if not sig all, can just sign inputs and no need to do a swap first - // check that public key in data is one wallet can sign for + } else { + // if not sig all, can just sign inputs and no need to do a swap first if !nut11.CanSign(nut10secret, w.privateKey) { return nil, fmt.Errorf("cannot sign locked proofs") } @@ -788,8 +781,7 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string, pubkeyLock *b var secrets []string var rs []*secp256k1.PrivateKey var counter, incrementCounterBy uint32 - // check here if lock is present and if so, use it to generate blinded messages - // instead of generating from counter + if pubkeyLock == nil { counter = w.counterForKeyset(activeSatKeyset.Id) var err error diff --git a/wallet/wallet_integration_test.go b/wallet/wallet_integration_test.go index 2df41a7..9da9f4f 100644 --- a/wallet/wallet_integration_test.go +++ b/wallet/wallet_integration_test.go @@ -377,13 +377,99 @@ func TestWalletBalance(t *testing.T) { } } -func TestWalletRestore(t *testing.T) { +func TestSendToPubkey(t *testing.T) { nutshellMint, err := testutils.CreateNutshellMintContainer(ctx) if err != nil { t.Fatalf("error starting nutshell mint: %v", err) } defer nutshellMint.Terminate(ctx) + nutshellURL := nutshellMint.Host + + nutshellMint2, err := testutils.CreateNutshellMintContainer(ctx) + if err != nil { + t.Fatalf("error starting nutshell mint: %v", err) + } + defer nutshellMint2.Terminate(ctx) + + testWalletPath := filepath.Join(".", "/testwalletp2pk") + testWallet, err := testutils.CreateTestWallet(testWalletPath, nutshellURL) + if err != nil { + t.Fatal(err) + } + defer func() { + os.RemoveAll(testWalletPath) + }() + + testWalletPath2 := filepath.Join(".", "/testwalletp2pk2") + testWallet2, err := testutils.CreateTestWallet(testWalletPath2, nutshellMint2.Host) + if err != nil { + t.Fatal(err) + } + defer func() { + os.RemoveAll(testWalletPath2) + }() + + mintRequest, err := testWallet.RequestMint(20000) + if err != nil { + t.Fatalf("unexpected error in mint request: %v", err) + } + _, err = testWallet.MintTokens(mintRequest.Quote) + if err != nil { + t.Fatalf("unexpected error in mint tokens: %v", err) + } + + receiverPubkey := testWallet2.GetReceivePubkey() + lockedEcash, err := testWallet.SendToPubkey(500, nutshellURL, receiverPubkey) + if err != nil { + t.Fatalf("unexpected error generating locked ecash: %v", err) + } + + // try receiving invalid + _, err = testWallet.Receive(*lockedEcash, true) + if err == nil { + t.Fatal("expected error trying to redeem locked ecash") + } + + // this should unlock ecash and swap to trusted mint + amountReceived, err := testWallet2.Receive(*lockedEcash, true) + if err != nil { + t.Fatalf("unexpected error receiving locked ecash: %v", err) + } + + trustedMints := testWallet2.TrustedMints() + if len(trustedMints) != 1 { + t.Fatalf("expected len of trusted mints '%v' but got '%v' instead", 1, len(trustedMints)) + } + + balance := testWallet2.GetBalance() + if balance != amountReceived { + t.Fatalf("expected balance of '%v' but got '%v' instead", amountReceived, balance) + } + + lockedEcash, err = testWallet.SendToPubkey(500, nutshellURL, receiverPubkey) + if err != nil { + t.Fatalf("unexpected error generating locked ecash: %v", err) + } + + // unlock ecash and trust mint + amountReceived, err = testWallet2.Receive(*lockedEcash, false) + if err != nil { + t.Fatalf("unexpected error receiving locked ecash: %v", err) + } + + trustedMints = testWallet2.TrustedMints() + if len(trustedMints) != 2 { + t.Fatalf("expected len of trusted mints '%v' but got '%v' instead", 2, len(trustedMints)) + } +} + +func TestWalletRestore(t *testing.T) { + nutshellMint, err := testutils.CreateNutshellMintContainer(ctx) + if err != nil { + t.Fatalf("error starting nutshell mint: %v", err) + } + defer nutshellMint.Terminate(ctx) mintURL := nutshellMint.Host testWalletPath := filepath.Join(".", "/testrestorewallet") @@ -391,6 +477,9 @@ func TestWalletRestore(t *testing.T) { if err != nil { t.Fatal(err) } + defer func() { + os.RemoveAll(testWalletPath) + }() testWalletPath2 := filepath.Join(".", "/testrestorewallet2") testWallet2, err := testutils.CreateTestWallet(testWalletPath2, mintURL)