diff --git a/cashu/cashu.go b/cashu/cashu.go index db5e9b0..ed5fc34 100644 --- a/cashu/cashu.go +++ b/cashu/cashu.go @@ -4,22 +4,52 @@ package cashu import ( "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" + + "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +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"` - 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 { @@ -41,16 +71,50 @@ 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"` } +func (p Proof) IsSecretP2PK() bool { + return p.SecretType() == P2PK +} + +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 P2PK: + return "P2PK" + default: + return "random" + } +} + type Proofs []Proof // Amount returns the total amount from 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.go b/cashu/nuts/nut10/nut10.go new file mode 100644 index 0000000..1e73b30 --- /dev/null +++ b/cashu/nuts/nut10/nut10.go @@ -0,0 +1,53 @@ +package nut10 + +import ( + "encoding/json" + "errors" + "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 +} + +// 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/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.go b/cashu/nuts/nut11/nut11.go new file mode 100644 index 0000000..889ea87 --- /dev/null +++ b/cashu/nuts/nut11/nut11.go @@ -0,0 +1,129 @@ +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" +) + +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 +} + +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/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/cmd/nutw/nutw.go b/cmd/nutw/nutw.go index 7899e2d..61fa11d 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" @@ -101,6 +103,7 @@ func main() { sendCmd, receiveCmd, payCmd, + p2pkLockCmd, mnemonicCmd, restoreCmd, }, @@ -259,9 +262,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 +289,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()) @@ -316,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/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..a788f27 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1,6 +1,7 @@ package wallet import ( + "crypto/rand" "encoding/hex" "errors" "fmt" @@ -10,6 +11,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 +22,8 @@ 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" "github.com/elnosh/gonuts/wallet/storage" @@ -37,6 +41,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 +93,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 @@ -340,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) } @@ -383,7 +395,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,10 +404,36 @@ 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) { - 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) @@ -409,50 +447,105 @@ 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 { - activeSatKeyset = k - break + proofs, err := w.swap(proofsToSwap, tokenMintURL) + if err != nil { + return 0, err } - counter := w.counterForKeyset(activeSatKeyset.Id) - // create blinded messages - outputs, secrets, rs, err := w.createBlindedMessages(token.TotalAmount(), activeSatKeyset.Id, counter) + 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 0, fmt.Errorf("createBlindedMessages: %v", err) + 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") } - // make swap request to mint - swapRequest := nut03.PostSwapRequest{Inputs: proofsToSwap, Outputs: outputs} - swapResponse, err := PostSwap(tokenMintURL, swapRequest) + proofsToSwap, err = nut11.AddSignatureToInputs(proofsToSwap, w.privateKey) if err != nil { - return 0, err + return nil, fmt.Errorf("error signing inputs: %v", err) } + } - // unblind signatures to get proofs and save them to db - proofs, err := constructProofs(swapResponse.Signatures, secrets, rs, &activeSatKeyset) + var activeKeysets map[string]crypto.WalletKeyset + walletMint, trustedMint := w.mints[mintURL] + if !trustedMint { + // get keys if mint not trusted + var err error + activeKeysets, err = GetMintActiveKeysets(mintURL) if err != nil { - return 0, fmt.Errorf("wallet.ConstructProofs: %v", err) + return nil, err } + } else { + activeKeysets = walletMint.activeKeysets + } - w.saveProofs(proofs) + var activeSatKeyset crypto.WalletKeyset + for _, k := range activeKeysets { + activeSatKeyset = k + break + } + var counter *uint32 = nil + if trustedMint { + keysetCounter := w.counterForKeyset(activeSatKeyset.Id) + counter = &keysetCounter + } - err = w.incrementKeysetCounter(activeSatKeyset.Id, uint32(len(outputs))) + 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 + if proofsToSwap[0].IsSecretP2PK() && nut11.IsSigAll(nut10secret) { + outputs, err = nut11.AddSignatureToOutputs(outputs, w.privateKey) if err != nil { - return 0, fmt.Errorf("error incrementing keyset counter: %v", err) + return nil, fmt.Errorf("error signing outputs: %v", err) } + } - return proofs.Amount(), nil + // 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 nil, fmt.Errorf("error incrementing keyset counter: %v", err) + } + } + + return proofs, nil } // swapToTrusted will swap the proofs from mint in the token @@ -498,6 +591,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 + 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) @@ -532,7 +651,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 +727,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 +758,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 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 { + for _, proof := range selectedProofs { + w.db.DeleteProof(proof.Secret) + } + return selectedProofs, nil } - return selectedProofs, nil } var activeSatKeyset crypto.WalletKeyset @@ -652,44 +776,47 @@ 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, incrementCounterBy uint32 - counter += uint32(len(send)) + 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 + } + incrementCounterBy += 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) + 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) 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] - } - } - } + cashu.SortBlindedMessages(blindedMessages, secrets, rs) swapRequest := nut03.PostSwapRequest{Inputs: selectedProofs, Outputs: blindedMessages} swapResponse, err := PostSwap(selectedMint.mintURL, swapRequest) @@ -720,7 +847,7 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string) (cashu.Proofs // 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) } @@ -729,7 +856,13 @@ func (w *Wallet) getProofsForAmount(amount uint64, mintURL string) (cashu.Proofs } // 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) @@ -743,47 +876,106 @@ 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 + return "", nil, err } - B_, r, err := crypto.BlindMessage(secret, r) - if err != nil { - return nil, "", nil, err + return 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] = cashu.NewBlindedMessage(keysetId, amt, B_) + secrets[i] = string(secret) + rs[i] = r } - return B_, secret, r, nil + return blindedMessages, secrets, rs, nil } // constructProofs unblinds the blindedSignatures and returns the proofs @@ -854,6 +1046,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 { @@ -961,6 +1158,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() } @@ -1059,7 +1262,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_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) 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)