diff --git a/.env.mint.example b/.env.mint.example index 8d63f5f..c9aa37f 100644 --- a/.env.mint.example +++ b/.env.mint.example @@ -1,7 +1,5 @@ -# mint -MINT_PRIVATE_KEY="mykey" -# use only if setting up mint with one unit (defaults to sat) -MINT_DERIVATION_PATH="0/0/0" +# Bump this number to generate new active keyset and deactivate previous one +DERIVATION_PATH_IDX=0 # fee to charge per input (in parts per thousand) INPUT_FEE_PPK=100 diff --git a/cashu/cashu.go b/cashu/cashu.go index a240431..d0c6d78 100644 --- a/cashu/cashu.go +++ b/cashu/cashu.go @@ -3,6 +3,8 @@ package cashu import ( + "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" @@ -218,6 +220,7 @@ const ( QuoteErrCode InvoiceErrCode ProofsErrCode + DBErrorCode ) var ( @@ -236,6 +239,8 @@ var ( InvoiceTokensIssuedErr = Error{Detail: "tokens already issued for invoice", Code: InvoiceErrCode} ProofAlreadyUsedErr = Error{Detail: "proofs already used", Code: ProofsErrCode} InvalidProofErr = Error{Detail: "invalid proof", Code: ProofsErrCode} + NoProofsProvided = Error{Detail: "no proofs provided", Code: ProofsErrCode} + DuplicateProofs = Error{Detail: "duplicate proofs", Code: ProofsErrCode} InputsBelowOutputs = Error{Detail: "amount of input proofs is below amount of outputs", Code: ProofsErrCode} EmptyInputsErr = Error{Detail: "inputs cannot be empty", Code: ProofsErrCode} QuoteNotExistErr = Error{Detail: "quote does not exist", Code: QuoteErrCode} @@ -259,6 +264,30 @@ func AmountSplit(amount uint64) []uint64 { return rv } +func CheckDuplicateProofs(proofs Proofs) bool { + proofsMap := make(map[Proof]bool) + + for _, proof := range proofs { + if proofsMap[proof] { + return true + } else { + proofsMap[proof] = true + } + } + + return false +} + +func GenerateRandomQuoteId() (string, error) { + randomBytes := make([]byte, 32) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + hash := sha256.Sum256(randomBytes) + return hex.EncodeToString(hash[:]), nil +} + func Max(x, y uint64) uint64 { if x > y { return x diff --git a/cashu/nuts/nut04/nut04.go b/cashu/nuts/nut04/nut04.go index e18ce1c..502c59d 100644 --- a/cashu/nuts/nut04/nut04.go +++ b/cashu/nuts/nut04/nut04.go @@ -53,7 +53,7 @@ type PostMintQuoteBolt11Response struct { Request string `json:"request"` State State `json:"state"` Paid bool `json:"paid"` // DEPRECATED: use State instead - Expiry int64 `json:"expiry"` + Expiry uint64 `json:"expiry"` } type PostMintBolt11Request struct { @@ -70,7 +70,7 @@ type TempQuote struct { Request string `json:"request"` State string `json:"state"` Paid bool `json:"paid"` // DEPRECATED: use State instead - Expiry int64 `json:"expiry"` + Expiry uint64 `json:"expiry"` } func (quoteResponse *PostMintQuoteBolt11Response) MarshalJSON() ([]byte, error) { diff --git a/cashu/nuts/nut05/nut05.go b/cashu/nuts/nut05/nut05.go index a64dad1..989917c 100644 --- a/cashu/nuts/nut05/nut05.go +++ b/cashu/nuts/nut05/nut05.go @@ -54,7 +54,7 @@ type PostMeltQuoteBolt11Response struct { FeeReserve uint64 `json:"fee_reserve"` State State `json:"state"` Paid bool `json:"paid"` // DEPRECATED: use state instead - Expiry int64 `json:"expiry"` + Expiry uint64 `json:"expiry"` Preimage string `json:"payment_preimage,omitempty"` } @@ -69,7 +69,7 @@ type TempQuote struct { FeeReserve uint64 `json:"fee_reserve"` State string `json:"state"` Paid bool `json:"paid"` // DEPRECATED: use state instead - Expiry int64 `json:"expiry"` + Expiry uint64 `json:"expiry"` Preimage string `json:"payment_preimage,omitempty"` } diff --git a/crypto/bdhke.go b/crypto/bdhke.go index fb886af..72ba1fb 100644 --- a/crypto/bdhke.go +++ b/crypto/bdhke.go @@ -111,13 +111,7 @@ func Verify(secret string, k *secp256k1.PrivateKey, C *secp256k1.PublicKey) bool if err != nil { return false } - valid := verify(Y, k, C) - if !valid { - Y := HashToCurveDeprecated([]byte(secret)) - valid = verify(Y, k, C) - } - - return valid + return verify(Y, k, C) } func verify(Y *secp256k1.PublicKey, k *secp256k1.PrivateKey, C *secp256k1.PublicKey) bool { @@ -130,34 +124,3 @@ func verify(Y *secp256k1.PublicKey, k *secp256k1.PrivateKey, C *secp256k1.Public return C.IsEqual(pk) } - -// Deprecated HashToCurve - -func HashToCurveDeprecated(message []byte) *secp256k1.PublicKey { - var point *secp256k1.PublicKey - - for point == nil || !point.IsOnCurve() { - hash := sha256.Sum256(message) - pkhash := append([]byte{0x02}, hash[:]...) - point, _ = secp256k1.ParsePubKey(pkhash) - message = hash[:] - } - return point -} - -func BlindMessageDeprecated(secret string, r *secp256k1.PrivateKey) (*secp256k1.PublicKey, *secp256k1.PrivateKey) { - var ypoint, rpoint, blindedMessage secp256k1.JacobianPoint - - Y := HashToCurveDeprecated([]byte(secret)) - Y.AsJacobian(&ypoint) - - rpub := r.PubKey() - rpub.AsJacobian(&rpoint) - - // blindedMessage = Y + rG - secp256k1.AddNonConst(&ypoint, &rpoint, &blindedMessage) - blindedMessage.ToAffine() - B_ := secp256k1.NewPublicKey(&blindedMessage.X, &blindedMessage.Y) - - return B_, r -} diff --git a/crypto/bdhke_test.go b/crypto/bdhke_test.go index bdefa63..953b977 100644 --- a/crypto/bdhke_test.go +++ b/crypto/bdhke_test.go @@ -166,62 +166,3 @@ func TestVerify(t *testing.T) { t.Error("failed verification") } } - -// Tests for deprecated HashToCurve -func TestHashToCurveDeprecated(t *testing.T) { - tests := []struct { - message string - expected string - }{ - {message: "0000000000000000000000000000000000000000000000000000000000000000", - expected: "0266687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925"}, - {message: "0000000000000000000000000000000000000000000000000000000000000001", - expected: "02ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5"}, - {message: "0000000000000000000000000000000000000000000000000000000000000002", - expected: "02076c988b353fcbb748178ecb286bc9d0b4acf474d4ba31ba62334e46c97c416a"}, - } - - for _, test := range tests { - msgBytes, err := hex.DecodeString(test.message) - if err != nil { - t.Errorf("error decoding msg: %v", err) - } - - pk := HashToCurveDeprecated(msgBytes) - hexStr := hex.EncodeToString(pk.SerializeCompressed()) - if hexStr != test.expected { - t.Errorf("expected '%v' but got '%v' instead\n", test.expected, hexStr) - } - } -} - -func TestBlindMessageDeprecated(t *testing.T) { - tests := []struct { - secret string - blindingFactor string - expected string - }{ - {secret: "test_message", - blindingFactor: "0000000000000000000000000000000000000000000000000000000000000001", - expected: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", - }, - {secret: "hello", - blindingFactor: "6d7e0abffc83267de28ed8ecc8760f17697e51252e13333ba69b4ddad1f95d05", - expected: "0249eb5dbb4fac2750991cf18083388c6ef76cde9537a6ac6f3e6679d35cdf4b0c", - }, - } - - for _, test := range tests { - rbytes, err := hex.DecodeString(test.blindingFactor) - if err != nil { - t.Errorf("error decoding blinding factor: %v", err) - } - r := secp256k1.PrivKeyFromBytes(rbytes) - - B_, _ := BlindMessageDeprecated(test.secret, r) - B_Hex := hex.EncodeToString(B_.SerializeCompressed()) - if B_Hex != test.expected { - t.Errorf("expected '%v' but got '%v' instead\n", test.expected, B_Hex) - } - } -} diff --git a/crypto/keyset.go b/crypto/keyset.go index 4f727f0..c7d982c 100644 --- a/crypto/keyset.go +++ b/crypto/keyset.go @@ -6,21 +6,21 @@ import ( "encoding/json" "math" "sort" - "strconv" - "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/elnosh/gonuts/cashu/nuts/nut01" ) const MAX_ORDER = 60 -type Keyset struct { - Id string - Unit string - Active bool - Keys map[uint64]KeyPair - InputFeePpk uint +type MintKeyset struct { + Id string + Unit string + Active bool + DerivationPathIdx uint32 + Keys map[uint64]KeyPair + InputFeePpk uint } type KeyPair struct { @@ -28,26 +28,66 @@ type KeyPair struct { PublicKey *secp256k1.PublicKey } -func GenerateKeyset(seed, derivationPath string, inputFeePpk uint) *Keyset { +func DeriveKeysetPath(key *hdkeychain.ExtendedKey, index uint32) (*hdkeychain.ExtendedKey, error) { + // path m/0' + child, err := key.Derive(hdkeychain.HardenedKeyStart + 0) + if err != nil { + return nil, err + } + + // path m/0'/0' for sat + unitPath, err := child.Derive(hdkeychain.HardenedKeyStart + 0) + if err != nil { + return nil, err + } + + // path m/0'/0'/index' + keysetPath, err := unitPath.Derive(hdkeychain.HardenedKeyStart + index) + if err != nil { + return nil, err + } + + return keysetPath, nil +} + +func GenerateKeyset(master *hdkeychain.ExtendedKey, index uint32, inputFeePpk uint) (*MintKeyset, error) { keys := make(map[uint64]KeyPair, MAX_ORDER) + keysetPath, err := DeriveKeysetPath(master, index) + if err != nil { + return nil, err + } + pks := make(map[uint64]*secp256k1.PublicKey) for i := 0; i < MAX_ORDER; i++ { amount := uint64(math.Pow(2, float64(i))) - hash := sha256.Sum256([]byte(seed + derivationPath + strconv.FormatUint(amount, 10))) - privKey, pubKey := btcec.PrivKeyFromBytes(hash[:]) + amountPath, err := keysetPath.Derive(hdkeychain.HardenedKeyStart + uint32(i)) + if err != nil { + return nil, err + } + + privKey, err := amountPath.ECPrivKey() + if err != nil { + return nil, err + } + pubKey, err := amountPath.ECPubKey() + if err != nil { + return nil, err + } + keys[amount] = KeyPair{PrivateKey: privKey, PublicKey: pubKey} pks[amount] = pubKey } keysetId := DeriveKeysetId(pks) - return &Keyset{ - Id: keysetId, - Unit: "sat", - Active: true, - Keys: keys, - InputFeePpk: inputFeePpk, - } + return &MintKeyset{ + Id: keysetId, + Unit: "sat", + Active: true, + DerivationPathIdx: index, + Keys: keys, + InputFeePpk: inputFeePpk, + }, nil } // DeriveKeysetId returns the string ID derived from the map keyset @@ -84,7 +124,7 @@ func DeriveKeysetId(keyset map[uint64]*secp256k1.PublicKey) string { // DerivePublic returns the keyset's public keys as // a map of amounts uint64 to strings that represents the public key -func (ks *Keyset) DerivePublic() map[uint64]string { +func (ks *MintKeyset) DerivePublic() map[uint64]string { pubkeys := make(map[uint64]string) for amount, key := range ks.Keys { pubkey := hex.EncodeToString(key.PublicKey.SerializeCompressed()) @@ -101,7 +141,7 @@ type KeysetTemp struct { InputFeePpk uint } -func (ks *Keyset) MarshalJSON() ([]byte, error) { +func (ks *MintKeyset) MarshalJSON() ([]byte, error) { temp := &KeysetTemp{ Id: ks.Id, Unit: ks.Unit, @@ -120,7 +160,7 @@ func (ks *Keyset) MarshalJSON() ([]byte, error) { return json.Marshal(temp) } -func (ks *Keyset) UnmarshalJSON(data []byte) error { +func (ks *MintKeyset) UnmarshalJSON(data []byte) error { temp := &KeysetTemp{} if err := json.Unmarshal(data, &temp); err != nil { diff --git a/go.mod b/go.mod index 8b1c1a4..583bc5f 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/gorilla/mux v1.8.0 github.com/joho/godotenv v1.5.1 github.com/lightningnetwork/lnd v0.17.4-beta + github.com/mattn/go-sqlite3 v1.14.22 github.com/nbd-wtf/ln-decodepay v1.12.1 github.com/testcontainers/testcontainers-go v0.31.0 github.com/tyler-smith/go-bip39 v1.1.0 @@ -67,7 +68,7 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.4.2 // indirect - github.com/golang-migrate/migrate/v4 v4.17.0 // indirect + github.com/golang-migrate/migrate/v4 v4.17.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/btree v1.0.1 // indirect diff --git a/go.sum b/go.sum index d1f83af..30f7f09 100644 --- a/go.sum +++ b/go.sum @@ -171,6 +171,7 @@ github.com/decred/dcrd/lru v1.1.2 h1:KdCzlkxppuoIDGEvCGah1fZRicrDH36IipvlB1ROkFY github.com/decred/dcrd/lru v1.1.2/go.mod h1:gEdCVgXs1/YoBvFWt7Scgknbhwik3FgVSzlnCcXL2N8= github.com/dhui/dktest v0.4.0 h1:z05UmuXZHO/bgj/ds2bGMBu8FI4WA+Ag/m3ghL+om7M= github.com/dhui/dktest v0.4.0/go.mod h1:v/Dbz1LgCBOi2Uki2nUqLBGa83hWBGFMu5MrgMDCc78= +github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg= github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v23.0.3+incompatible h1:Zcse1DuDqBdgI7OQDV8Go7b83xLgfhW1eza4HfEdxpY= @@ -239,6 +240,8 @@ github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQA github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU= github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= diff --git a/mint/config.go b/mint/config.go index 9bd1ed2..3bbef3d 100644 --- a/mint/config.go +++ b/mint/config.go @@ -8,16 +8,17 @@ import ( "os" "strconv" - "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" "github.com/elnosh/gonuts/cashu/nuts/nut06" ) type Config struct { - PrivateKey string - DerivationPath string - Port string - DBPath string - InputFeePpk uint + DerivationPathIdx uint32 + Port string + DBPath string + DBMigrationPath string + InputFeePpk uint } func GetConfig() Config { @@ -30,18 +31,23 @@ func GetConfig() Config { inputFeePpk = uint(fee) } + derivationPathIdx, err := strconv.ParseUint(os.Getenv("DERIVATION_PATH_IDX"), 10, 32) + if err != nil { + log.Fatalf("invalid DERIVATION_PATH_IDX: %v", err) + } + return Config{ - PrivateKey: os.Getenv("MINT_PRIVATE_KEY"), - DerivationPath: os.Getenv("MINT_DERIVATION_PATH"), - Port: os.Getenv("MINT_PORT"), - DBPath: os.Getenv("MINT_DB_PATH"), - InputFeePpk: inputFeePpk, + DerivationPathIdx: uint32(derivationPathIdx), + Port: os.Getenv("MINT_PORT"), + DBPath: os.Getenv("MINT_DB_PATH"), + DBMigrationPath: "../../mint/storage/sqlite/migrations", + InputFeePpk: inputFeePpk, } } // getMintInfo returns information about the mint as // defined in NUT-06: https://github.com/cashubtc/nuts/blob/main/06.md -func getMintInfo() (*nut06.MintInfo, error) { +func (m *Mint) getMintInfo() (*nut06.MintInfo, error) { mintInfo := nut06.MintInfo{ Name: os.Getenv("MINT_NAME"), Version: "gonuts/0.0.1", @@ -51,8 +57,22 @@ func getMintInfo() (*nut06.MintInfo, error) { mintInfo.LongDescription = os.Getenv("MINT_DESCRIPTION_LONG") mintInfo.Motd = os.Getenv("MINT_MOTD") - privateKey := secp256k1.PrivKeyFromBytes([]byte(os.Getenv("MINT_PRIVATE_KEY"))) - mintInfo.Pubkey = hex.EncodeToString(privateKey.PubKey().SerializeCompressed()) + seed, err := m.db.GetSeed() + if err != nil { + return nil, err + } + + master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) + if err != nil { + return nil, err + } + + publicKey, err := master.ECPubKey() + if err != nil { + return nil, err + } + + mintInfo.Pubkey = hex.EncodeToString(publicKey.SerializeCompressed()) contact := os.Getenv("MINT_CONTACT_INFO") var mintContactInfo []nut06.ContactInfo diff --git a/mint/db.go b/mint/db.go deleted file mode 100644 index 4cb6451..0000000 --- a/mint/db.go +++ /dev/null @@ -1,217 +0,0 @@ -package mint - -import ( - "encoding/json" - "fmt" - "path/filepath" - - "github.com/elnosh/gonuts/cashu" - "github.com/elnosh/gonuts/crypto" - "github.com/elnosh/gonuts/mint/lightning" - bolt "go.etcd.io/bbolt" -) - -const ( - keysetsBucket = "keysets" - invoicesBucket = "invoices" - - // for all redeemed proofs - proofsBucket = "proofs" - - quotesBucket = "quotes" -) - -type BoltDB struct { - bolt *bolt.DB -} - -func InitBolt(path string) (*BoltDB, error) { - db, err := bolt.Open(filepath.Join(path, "mint.db"), 0600, nil) - if err != nil { - return nil, fmt.Errorf("error setting bolt db: %v", err) - } - - boltdb := &BoltDB{bolt: db} - err = boltdb.initMintBuckets() - if err != nil { - return nil, fmt.Errorf("error setting bolt db: %v", err) - } - - return boltdb, nil -} - -func (db *BoltDB) initMintBuckets() error { - return db.bolt.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(keysetsBucket)) - if err != nil { - return err - } - - _, err = tx.CreateBucketIfNotExists([]byte(invoicesBucket)) - if err != nil { - return err - } - - _, err = tx.CreateBucketIfNotExists([]byte(proofsBucket)) - if err != nil { - return err - } - - _, err = tx.CreateBucketIfNotExists([]byte(quotesBucket)) - if err != nil { - return err - } - - return nil - }) -} - -func (db *BoltDB) GetKeysets() map[string]crypto.Keyset { - keysets := make(map[string]crypto.Keyset) - - db.bolt.View(func(tx *bolt.Tx) error { - keysetsBucket := tx.Bucket([]byte(keysetsBucket)) - - c := keysetsBucket.Cursor() - for k, v := c.First(); k != nil; k, v = c.Next() { - var keyset crypto.Keyset - if err := json.Unmarshal(v, &keyset); err != nil { - break - } - keysets[string(k)] = keyset - } - return nil - }) - - return keysets -} - -func (db *BoltDB) SaveKeyset(keyset *crypto.Keyset) error { - jsonKeyset, err := json.Marshal(keyset) - if err != nil { - return fmt.Errorf("invalid keyset: %v", err) - } - - if err := db.bolt.Update(func(tx *bolt.Tx) error { - keysetsb := tx.Bucket([]byte(keysetsBucket)) - key := []byte(keyset.Id) - return keysetsb.Put(key, jsonKeyset) - }); err != nil { - return fmt.Errorf("error saving keyset: %v", err) - } - return nil -} - -type dbproof struct { - Y []byte - Amount uint64 `json:"amount"` - Id string `json:"id"` - Secret string `json:"secret"` - C string `json:"C"` -} - -func (db *BoltDB) GetProof(secret string) *cashu.Proof { - var proof *cashu.Proof - Y := crypto.HashToCurveDeprecated([]byte(secret)) - - db.bolt.View(func(tx *bolt.Tx) error { - proofsb := tx.Bucket([]byte(proofsBucket)) - proofBytes := proofsb.Get(Y.SerializeCompressed()) - err := json.Unmarshal(proofBytes, &proof) - if err != nil { - proof = nil - } - return nil - }) - return proof -} - -func (db *BoltDB) SaveProof(proof cashu.Proof) error { - Y := crypto.HashToCurveDeprecated([]byte(proof.Secret)) - - dbproof := dbproof{ - Y: Y.SerializeCompressed(), - Amount: proof.Amount, - Id: proof.Id, - Secret: proof.Secret, - C: proof.C, - } - jsonProof, err := json.Marshal(dbproof) - if err != nil { - return fmt.Errorf("invalid proof format: %v", err) - } - - if err := db.bolt.Update(func(tx *bolt.Tx) error { - proofsb := tx.Bucket([]byte(proofsBucket)) - return proofsb.Put(Y.SerializeCompressed(), jsonProof) - }); err != nil { - return fmt.Errorf("error saving proof: %v", err) - } - return nil -} - -func (db *BoltDB) SaveInvoice(invoice lightning.Invoice) error { - jsonbytes, err := json.Marshal(invoice) - if err != nil { - return fmt.Errorf("invalid invoice: %v", err) - } - - if err := db.bolt.Update(func(tx *bolt.Tx) error { - invoicesb := tx.Bucket([]byte(invoicesBucket)) - key := []byte(invoice.Id) - err := invoicesb.Put(key, jsonbytes) - return err - }); err != nil { - return fmt.Errorf("error saving invoice: %v", err) - } - return nil -} - -func (db *BoltDB) GetInvoice(id string) *lightning.Invoice { - var invoice *lightning.Invoice - - db.bolt.View(func(tx *bolt.Tx) error { - invoicesb := tx.Bucket([]byte(invoicesBucket)) - invoiceBytes := invoicesb.Get([]byte(id)) - err := json.Unmarshal(invoiceBytes, &invoice) - if err != nil { - invoice = nil - } - - return nil - }) - return invoice -} - -func (db *BoltDB) SaveMeltQuote(quote MeltQuote) error { - jsonbytes, err := json.Marshal(quote) - if err != nil { - return fmt.Errorf("invalid quote: %v", err) - } - - if err := db.bolt.Update(func(tx *bolt.Tx) error { - meltQuotesb := tx.Bucket([]byte(quotesBucket)) - key := []byte(quote.Id) - err := meltQuotesb.Put(key, jsonbytes) - return err - }); err != nil { - return fmt.Errorf("error saving quote: %v", err) - } - return nil -} - -func (db *BoltDB) GetMeltQuote(quoteId string) *MeltQuote { - var quote *MeltQuote - - db.bolt.View(func(tx *bolt.Tx) error { - meltQuotesb := tx.Bucket([]byte(quotesBucket)) - quoteBytes := meltQuotesb.Get([]byte(quoteId)) - err := json.Unmarshal(quoteBytes, "e) - if err != nil { - quote = nil - } - - return nil - }) - return quote -} diff --git a/mint/lightning/lightning.go b/mint/lightning/lightning.go index 4ca5a35..97ba33c 100644 --- a/mint/lightning/lightning.go +++ b/mint/lightning/lightning.go @@ -36,12 +36,9 @@ func NewLightningClient() Client { } type Invoice struct { - Id string // random id generated by mint PaymentRequest string PaymentHash string - Preimage string Settled bool - Redeemed bool Amount uint64 - Expiry int64 // in unix timestamp + Expiry uint64 } diff --git a/mint/lightning/lnd.go b/mint/lightning/lnd.go index 0effe12..5a827ea 100644 --- a/mint/lightning/lnd.go +++ b/mint/lightning/lnd.go @@ -94,7 +94,7 @@ func (lnd *LndClient) CreateInvoice(amount uint64) (Invoice, error) { PaymentRequest: addInvoiceResponse.PaymentRequest, PaymentHash: hash, Amount: amount, - Expiry: time.Now().Add(time.Minute * InvoiceExpiryMins).Unix(), + Expiry: uint64(time.Now().Add(time.Minute * InvoiceExpiryMins).Unix()), } return invoice, nil } @@ -119,11 +119,6 @@ func (lnd *LndClient) InvoiceStatus(hash string) (Invoice, error) { Amount: uint64(lookupInvoiceResponse.Value), } - if invoiceSettled { - preimage := hex.EncodeToString(lookupInvoiceResponse.RPreimage) - invoice.Preimage = preimage - } - return invoice, nil } diff --git a/mint/mint.go b/mint/mint.go index 0d3d0c3..e107634 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -1,9 +1,9 @@ package mint import ( - "crypto/rand" - "crypto/sha256" + "database/sql" "encoding/hex" + "errors" "fmt" "log" "os" @@ -11,6 +11,8 @@ import ( "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" "github.com/elnosh/gonuts/cashu" "github.com/elnosh/gonuts/cashu/nuts/nut04" @@ -18,6 +20,8 @@ import ( "github.com/elnosh/gonuts/cashu/nuts/nut06" "github.com/elnosh/gonuts/crypto" "github.com/elnosh/gonuts/mint/lightning" + "github.com/elnosh/gonuts/mint/storage" + "github.com/elnosh/gonuts/mint/storage/sqlite" decodepay "github.com/nbd-wtf/ln-decodepay" ) @@ -28,13 +32,13 @@ const ( ) type Mint struct { - db *BoltDB + db storage.MintDB // active keysets - ActiveKeysets map[string]crypto.Keyset + ActiveKeysets map[string]crypto.MintKeyset // map of all keysets (both active and inactive) - Keysets map[string]crypto.Keyset + Keysets map[string]crypto.MintKeyset LightningClient lightning.Client MintInfo *nut06.MintInfo @@ -46,28 +50,99 @@ func LoadMint(config Config) (*Mint, error) { path = mintPath() } - db, err := InitBolt(path) + db, err := sqlite.InitSQLite(path, config.DBMigrationPath) if err != nil { log.Fatalf("error starting mint: %v", err) } - activeKeyset := crypto.GenerateKeyset(config.PrivateKey, config.DerivationPath, config.InputFeePpk) - mint := &Mint{db: db, ActiveKeysets: map[string]crypto.Keyset{activeKeyset.Id: *activeKeyset}} + seed, err := db.GetSeed() + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // generate new seed + for { + seed, err = hdkeychain.GenerateSeed(32) + if err == nil { + err = db.SaveSeed(seed) + if err != nil { + return nil, err + } + break + } + } + } else { + return nil, err + } + } + + master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) + if err != nil { + return nil, err + } - mint.db.SaveKeyset(activeKeyset) - mint.Keysets = mint.db.GetKeysets() + activeKeyset, err := crypto.GenerateKeyset(master, config.DerivationPathIdx, config.InputFeePpk) + if err != nil { + return nil, err + } + mint := &Mint{db: db, ActiveKeysets: map[string]crypto.MintKeyset{activeKeyset.Id: *activeKeyset}} + + dbKeysets, err := mint.db.GetKeysets() + if err != nil { + return nil, fmt.Errorf("error reading keysets from db: %v", err) + } + + activeKeysetNew := true + mintKeysets := make(map[string]crypto.MintKeyset) + for _, dbkeyset := range dbKeysets { + seed, err := hex.DecodeString(dbkeyset.Seed) + if err != nil { + return nil, err + } + + master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) + if err != nil { + return nil, err + } + + if dbkeyset.Id == activeKeyset.Id { + activeKeysetNew = false + } + keyset, err := crypto.GenerateKeyset(master, dbkeyset.DerivationPathIdx, dbkeyset.InputFeePpk) + if err != nil { + return nil, err + } + mintKeysets[keyset.Id] = *keyset + } + + // save active keyset if new + if activeKeysetNew { + hexseed := hex.EncodeToString(seed) + activeDbKeyset := storage.DBKeyset{ + Id: activeKeyset.Id, + Unit: activeKeyset.Unit, + Active: true, + Seed: hexseed, + DerivationPathIdx: activeKeyset.DerivationPathIdx, + InputFeePpk: activeKeyset.InputFeePpk, + } + err := mint.db.SaveKeyset(activeDbKeyset) + if err != nil { + return nil, fmt.Errorf("error saving new active keyset: %v", err) + } + } + + mint.Keysets = mintKeysets mint.Keysets[activeKeyset.Id] = *activeKeyset mint.LightningClient = lightning.NewLightningClient() - mint.MintInfo, err = getMintInfo() + mint.MintInfo, err = mint.getMintInfo() if err != nil { return nil, err } - for i, keyset := range mint.Keysets { + for _, keyset := range mint.Keysets { if keyset.Id != activeKeyset.Id && keyset.Active { keyset.Active = false - mint.db.SaveKeyset(&keyset) - mint.Keysets[i] = keyset + mint.db.UpdateKeysetActive(keyset.Id, false) + mint.Keysets[keyset.Id] = keyset } } @@ -91,79 +166,77 @@ func mintPath() string { } // RequestMintQuote will process a request to mint tokens -// and returns a mint quote response or an error. +// and returns a mint quote or an error. // The request to mint a token is explained in // NUT-04 here: https://github.com/cashubtc/nuts/blob/main/04.md. -func (m *Mint) RequestMintQuote(method string, amount uint64, unit string) (nut04.PostMintQuoteBolt11Response, error) { +func (m *Mint) RequestMintQuote(method string, amount uint64, unit string) (storage.MintQuote, error) { // only support bolt11 if method != BOLT11_METHOD { - return nut04.PostMintQuoteBolt11Response{}, cashu.PaymentMethodNotSupportedErr + return storage.MintQuote{}, cashu.PaymentMethodNotSupportedErr } // only support sat unit if unit != SAT_UNIT { - return nut04.PostMintQuoteBolt11Response{}, cashu.UnitNotSupportedErr + return storage.MintQuote{}, cashu.UnitNotSupportedErr } // get an invoice from the lightning backend invoice, err := m.requestInvoice(amount) if err != nil { - msg := fmt.Sprintf("error generating invoice: %v", err) - return nut04.PostMintQuoteBolt11Response{}, cashu.BuildCashuError(msg, cashu.InvoiceErrCode) + msg := fmt.Sprintf("error generating payment request: %v", err) + return storage.MintQuote{}, cashu.BuildCashuError(msg, cashu.InvoiceErrCode) } - err = m.db.SaveInvoice(*invoice) + quoteId, err := cashu.GenerateRandomQuoteId() if err != nil { - return nut04.PostMintQuoteBolt11Response{}, cashu.StandardErr + return storage.MintQuote{}, err + } + mintQuote := storage.MintQuote{ + Id: quoteId, + Amount: amount, + PaymentRequest: invoice.PaymentRequest, + PaymentHash: invoice.PaymentHash, + State: nut04.Unpaid, + Expiry: invoice.Expiry, } - reqMintQuoteResponse := nut04.PostMintQuoteBolt11Response{ - Quote: invoice.Id, - Request: invoice.PaymentRequest, - State: nut04.Unpaid, - Paid: invoice.Settled, // DEPRECATED: remove after wallets have upgraded - Expiry: invoice.Expiry, + err = m.db.SaveMintQuote(mintQuote) + if err != nil { + msg := fmt.Sprintf("error saving mint quote to db: %v", err) + return storage.MintQuote{}, cashu.BuildCashuError(msg, cashu.DBErrorCode) } - return reqMintQuoteResponse, nil + return mintQuote, nil } // GetMintQuoteState returns the state of a mint quote. // Used to check whether a mint quote has been paid. -func (m *Mint) GetMintQuoteState(method, quoteId string) (nut04.PostMintQuoteBolt11Response, error) { +func (m *Mint) GetMintQuoteState(method, quoteId string) (storage.MintQuote, error) { if method != BOLT11_METHOD { - return nut04.PostMintQuoteBolt11Response{}, cashu.PaymentMethodNotSupportedErr + return storage.MintQuote{}, cashu.PaymentMethodNotSupportedErr } - invoice := m.db.GetInvoice(quoteId) - if invoice == nil { - return nut04.PostMintQuoteBolt11Response{}, cashu.QuoteNotExistErr + mintQuote, err := m.db.GetMintQuote(quoteId) + if err != nil { + return storage.MintQuote{}, cashu.QuoteNotExistErr } // check if the invoice has been paid - status, err := m.LightningClient.InvoiceStatus(invoice.PaymentHash) + status, err := m.LightningClient.InvoiceStatus(mintQuote.PaymentHash) if err != nil { - msg := fmt.Sprintf("error getting invoice status: %v", err) - return nut04.PostMintQuoteBolt11Response{}, cashu.BuildCashuError(msg, cashu.InvoiceErrCode) + msg := fmt.Sprintf("error getting status of payment request: %v", err) + return storage.MintQuote{}, cashu.BuildCashuError(msg, cashu.InvoiceErrCode) } - state := nut04.Unpaid - if status.Settled { - invoice.Settled = status.Settled - state = nut04.Paid - if invoice.Redeemed { - state = nut04.Issued + if status.Settled && mintQuote.State == nut04.Unpaid { + mintQuote.State = nut04.Paid + err := m.db.UpdateMintQuoteState(mintQuote.Id, mintQuote.State) + if err != nil { + msg := fmt.Sprintf("error getting quote state: %v", err) + return storage.MintQuote{}, cashu.BuildCashuError(msg, cashu.DBErrorCode) } - m.db.SaveInvoice(*invoice) } - quoteState := nut04.PostMintQuoteBolt11Response{ - Quote: invoice.Id, - Request: invoice.PaymentRequest, - State: state, - Paid: invoice.Settled, // DEPRECATED: remove after wallets have upgraded - Expiry: invoice.Expiry, - } - return quoteState, nil + return *mintQuote, nil } // MintTokens verifies whether the mint quote with id has been paid and proceeds to @@ -173,25 +246,23 @@ func (m *Mint) MintTokens(method, id string, blindedMessages cashu.BlindedMessag return nil, cashu.PaymentMethodNotSupportedErr } - invoice := m.db.GetInvoice(id) - if invoice == nil { - return nil, cashu.InvoiceNotExistErr + mintQuote, err := m.db.GetMintQuote(id) + if err != nil { + return nil, cashu.QuoteNotExistErr } - var blindedSignatures cashu.BlindedSignatures - status, err := m.LightningClient.InvoiceStatus(invoice.PaymentHash) + status, err := m.LightningClient.InvoiceStatus(mintQuote.PaymentHash) if err != nil { - msg := fmt.Sprintf("error getting invoice status: %v", err) + msg := fmt.Sprintf("error getting status of payment request: %v", err) return nil, cashu.BuildCashuError(msg, cashu.InvoiceErrCode) } if status.Settled { - if invoice.Redeemed { + if mintQuote.State == nut04.Issued { return nil, cashu.InvoiceTokensIssuedErr } blindedMessagesAmount := blindedMessages.Amount() - // check overflow if len(blindedMessages) > 0 { for _, msg := range blindedMessages { @@ -201,9 +272,9 @@ func (m *Mint) MintTokens(method, id string, blindedMessages cashu.BlindedMessag } } - // verify that amount from invoice is less than the amount - // from the blinded messages - if blindedMessagesAmount > invoice.Amount { + // verify that amount from blinded messages is less + // than quote amount + if blindedMessagesAmount > mintQuote.Amount { return nil, cashu.OutputsOverInvoiceErr } @@ -213,10 +284,12 @@ func (m *Mint) MintTokens(method, id string, blindedMessages cashu.BlindedMessag return nil, err } - // mark invoice as redeemed after signing the blinded messages - invoice.Settled = true - invoice.Redeemed = true - m.db.SaveInvoice(*invoice) + // mark quote as issued after signing the blinded messages + err = m.db.UpdateMintQuoteState(mintQuote.Id, nut04.Issued) + if err != nil { + msg := fmt.Sprintf("error getting quote state: %v", err) + return nil, cashu.BuildCashuError(msg, cashu.DBErrorCode) + } } else { return nil, cashu.InvoiceNotPaidErr } @@ -230,7 +303,24 @@ func (m *Mint) MintTokens(method, id string, blindedMessages cashu.BlindedMessag // the proofs that were used as input. // It returns the BlindedSignatures. func (m *Mint) Swap(proofs cashu.Proofs, blindedMessages cashu.BlindedMessages) (cashu.BlindedSignatures, error) { - proofsAmount := proofs.Amount() + proofsLen := len(proofs) + if proofsLen == 0 { + return nil, cashu.NoProofsProvided + } + + var proofsAmount uint64 + Ys := make([]string, proofsLen) + for i, proof := range proofs { + proofsAmount += proof.Amount + + Y, err := crypto.HashToCurve([]byte(proof.Secret)) + if err != nil { + return nil, cashu.InvalidProofErr + } + Yhex := hex.EncodeToString(Y.SerializeCompressed()) + Ys[i] = Yhex + } + blindedMessagesAmount := blindedMessages.Amount() // check overflow if len(blindedMessages) > 0 { @@ -245,92 +335,93 @@ func (m *Mint) Swap(proofs cashu.Proofs, blindedMessages cashu.BlindedMessages) return nil, cashu.InsufficientProofsAmount } - err := m.verifyProofs(proofs) + // check if proofs were alredy used + usedProofs, err := m.db.GetProofsUsed(Ys) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("could not get used proofs from db: %v", err) + return nil, cashu.BuildCashuError(msg, cashu.DBErrorCode) + } + } + if len(usedProofs) != 0 { + return nil, cashu.ProofAlreadyUsedErr + } + + err = m.verifyProofs(proofs) if err != nil { return nil, err } - // if verification complete, sign blinded messages and invalidate used proofs - // by adding them to the db + // if verification complete, sign blinded messages blindedSignatures, err := m.signBlindedMessages(blindedMessages) if err != nil { return nil, err } - for _, proof := range proofs { - m.db.SaveProof(proof) + // invalidate proofs after signing blinded messages + err = m.db.SaveProofs(proofs) + if err != nil { + msg := fmt.Sprintf("error invalidating proofs. Could not save proofs to db: %v", err) + return nil, cashu.BuildCashuError(msg, cashu.DBErrorCode) } return blindedSignatures, nil } -type MeltQuote struct { - Id string - InvoiceRequest string - PaymentHash string - Amount uint64 - FeeReserve uint64 - State nut05.State - Paid bool // DEPRECATED: use state instead - Expiry int64 - Preimage string -} - // MeltRequest will process a request to melt tokens and return a MeltQuote. // A melt is requested by a wallet to request the mint to pay an invoice. -func (m *Mint) MeltRequest(method, request, unit string) (MeltQuote, error) { +func (m *Mint) MeltRequest(method, request, unit string) (storage.MeltQuote, error) { if method != BOLT11_METHOD { - return MeltQuote{}, cashu.PaymentMethodNotSupportedErr + return storage.MeltQuote{}, cashu.PaymentMethodNotSupportedErr } if unit != SAT_UNIT { - return MeltQuote{}, cashu.UnitNotSupportedErr + return storage.MeltQuote{}, cashu.UnitNotSupportedErr } // check invoice passed is valid bolt11, err := decodepay.Decodepay(request) if err != nil { msg := fmt.Sprintf("invalid invoice: %v", err) - return MeltQuote{}, cashu.BuildCashuError(msg, cashu.InvoiceErrCode) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.InvoiceErrCode) } - // generate random id for melt quote - randomBytes := make([]byte, 32) - _, err = rand.Read(randomBytes) + quoteId, err := cashu.GenerateRandomQuoteId() if err != nil { - return MeltQuote{}, cashu.StandardErr + return storage.MeltQuote{}, cashu.StandardErr } - hash := sha256.Sum256(randomBytes) satAmount := uint64(bolt11.MSatoshi) / 1000 // Fee reserve that is required by the mint fee := m.LightningClient.FeeReserve(satAmount) - expiry := time.Now().Add(time.Minute * QuoteExpiryMins).Unix() + expiry := uint64(time.Now().Add(time.Minute * QuoteExpiryMins).Unix()) - meltQuote := MeltQuote{ - Id: hex.EncodeToString(hash[:]), + meltQuote := storage.MeltQuote{ + Id: quoteId, InvoiceRequest: request, PaymentHash: bolt11.PaymentHash, Amount: satAmount, FeeReserve: fee, State: nut05.Unpaid, - Paid: false, Expiry: expiry, } - m.db.SaveMeltQuote(meltQuote) + if err := m.db.SaveMeltQuote(meltQuote); err != nil { + msg := fmt.Sprintf("error saving melt quote to db: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrorCode) + } return meltQuote, nil } // GetMeltQuoteState returns the state of a melt quote. // Used to check whether a melt quote has been paid. -func (m *Mint) GetMeltQuoteState(method, quoteId string) (MeltQuote, error) { +func (m *Mint) GetMeltQuoteState(method, quoteId string) (storage.MeltQuote, error) { if method != BOLT11_METHOD { - return MeltQuote{}, cashu.PaymentMethodNotSupportedErr + return storage.MeltQuote{}, cashu.PaymentMethodNotSupportedErr } - meltQuote := m.db.GetMeltQuote(quoteId) - if meltQuote == nil { - return MeltQuote{}, cashu.QuoteNotExistErr + meltQuote, err := m.db.GetMeltQuote(quoteId) + if err != nil { + return storage.MeltQuote{}, cashu.QuoteNotExistErr } return *meltQuote, nil @@ -338,47 +429,52 @@ func (m *Mint) GetMeltQuoteState(method, quoteId string) (MeltQuote, error) { // MeltTokens verifies whether proofs provided are valid // and proceeds to attempt payment. -func (m *Mint) MeltTokens(method, quoteId string, proofs cashu.Proofs) (MeltQuote, error) { +func (m *Mint) MeltTokens(method, quoteId string, proofs cashu.Proofs) (storage.MeltQuote, error) { if method != BOLT11_METHOD { - return MeltQuote{}, cashu.PaymentMethodNotSupportedErr + return storage.MeltQuote{}, cashu.PaymentMethodNotSupportedErr } - meltQuote := m.db.GetMeltQuote(quoteId) - if meltQuote == nil { - return MeltQuote{}, cashu.QuoteNotExistErr + meltQuote, err := m.db.GetMeltQuote(quoteId) + if err != nil { + return storage.MeltQuote{}, cashu.QuoteNotExistErr } if meltQuote.State == nut05.Paid { - return MeltQuote{}, cashu.QuoteAlreadyPaid + return storage.MeltQuote{}, cashu.QuoteAlreadyPaid } - err := m.verifyProofs(proofs) + err = m.verifyProofs(proofs) if err != nil { - return MeltQuote{}, err + return storage.MeltQuote{}, err } proofsAmount := proofs.Amount() fees := m.TransactionFees(proofs) // checks if amount in proofs is enough if proofsAmount < meltQuote.Amount+meltQuote.FeeReserve+uint64(fees) { - return MeltQuote{}, cashu.InsufficientProofsAmount + return storage.MeltQuote{}, cashu.InsufficientProofsAmount } // if proofs are valid, ask the lightning backend // to make the payment preimage, err := m.LightningClient.SendPayment(meltQuote.InvoiceRequest, meltQuote.Amount) if err != nil { - return MeltQuote{}, cashu.BuildCashuError(err.Error(), cashu.InvoiceErrCode) + return storage.MeltQuote{}, cashu.BuildCashuError(err.Error(), cashu.InvoiceErrCode) } // if payment succeeded, mark melt quote as paid // and invalidate proofs meltQuote.State = nut05.Paid - // Deprecate Paid field in favor of State - meltQuote.Paid = true meltQuote.Preimage = preimage - m.db.SaveMeltQuote(*meltQuote) - for _, proof := range proofs { - m.db.SaveProof(proof) + err = m.db.UpdateMeltQuote(meltQuote.Id, meltQuote.Preimage, meltQuote.State) + if err != nil { + msg := fmt.Sprintf("error getting quote state: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrorCode) + } + + err = m.db.SaveProofs(proofs) + if err != nil { + msg := fmt.Sprintf("error invalidating proofs. Could not save proofs to db: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrorCode) } return *meltQuote, nil @@ -388,13 +484,13 @@ func (m *Mint) verifyProofs(proofs cashu.Proofs) error { if len(proofs) == 0 { return cashu.EmptyInputsErr } - for _, proof := range proofs { - // if proof is already in db, it means it was already used - dbProof := m.db.GetProof(proof.Secret) - if dbProof != nil { - return cashu.ProofAlreadyUsedErr - } + // check duplicte proofs + if cashu.CheckDuplicateProofs(proofs) { + return cashu.DuplicateProofs + } + + for _, proof := range proofs { // check that id in the proof matches id of any // of the mint's keyset var k *secp256k1.PrivateKey @@ -471,15 +567,6 @@ func (m *Mint) requestInvoice(amount uint64) (*lightning.Invoice, error) { if err != nil { return nil, err } - - randomBytes := make([]byte, 32) - _, err = rand.Read(randomBytes) - if err != nil { - return nil, err - } - hash := sha256.Sum256(randomBytes) - invoice.Id = hex.EncodeToString(hash[:]) - return &invoice, nil } diff --git a/mint/mint_integration_test.go b/mint/mint_integration_test.go index e123903..c5e8bc5 100644 --- a/mint/mint_integration_test.go +++ b/mint/mint_integration_test.go @@ -23,11 +23,12 @@ import ( ) var ( - ctx context.Context - bitcoind *btcdocker.Bitcoind - lnd1 *btcdocker.Lnd - lnd2 *btcdocker.Lnd - testMint *mint.Mint + ctx context.Context + bitcoind *btcdocker.Bitcoind + lnd1 *btcdocker.Lnd + lnd2 *btcdocker.Lnd + testMint *mint.Mint + dbMigrationPath = "./storage/sqlite/migrations" ) func TestMain(m *testing.M) { @@ -81,7 +82,7 @@ func testMain(m *testing.M) int { } testMintPath := filepath.Join(".", "testmint1") - testMint, err = testutils.CreateTestMint(lnd1, "mykey", testMintPath, 0) + testMint, err = testutils.CreateTestMint(lnd1, testMintPath, dbMigrationPath, 0) if err != nil { log.Println(err) return 1 @@ -120,14 +121,14 @@ func TestMintQuoteState(t *testing.T) { t.Fatalf("error requesting mint quote: %v", err) } - var keyset crypto.Keyset + var keyset crypto.MintKeyset for _, k := range testMint.ActiveKeysets { keyset = k break } // test invalid method - _, err = testMint.GetMintQuoteState("strike", mintQuoteResponse.Quote) + _, err = testMint.GetMintQuoteState("strike", mintQuoteResponse.Id) if !errors.Is(err, cashu.PaymentMethodNotSupportedErr) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.PaymentMethodNotSupportedErr, err) } @@ -139,20 +140,17 @@ func TestMintQuoteState(t *testing.T) { } // test quote state before paying invoice - quoteStateResponse, err := testMint.GetMintQuoteState(testutils.BOLT11_METHOD, mintQuoteResponse.Quote) + quoteStateResponse, err := testMint.GetMintQuoteState(testutils.BOLT11_METHOD, mintQuoteResponse.Id) if err != nil { t.Fatalf("unexpected error getting quote state: %v", err) } - if quoteStateResponse.Paid { - t.Fatalf("expected quote.Paid '%v' but got '%v' instead", false, quoteStateResponse.Paid) - } if quoteStateResponse.State != nut04.Unpaid { t.Fatalf("expected quote state '%v' but got '%v' instead", nut04.Unpaid.String(), quoteStateResponse.State.String()) } //pay invoice sendPaymentRequest := lnrpc.SendRequest{ - PaymentRequest: mintQuoteResponse.Request, + PaymentRequest: mintQuoteResponse.PaymentRequest, } response, _ := lnd2.Client.SendPaymentSync(ctx, &sendPaymentRequest) if len(response.PaymentError) > 0 { @@ -160,13 +158,10 @@ func TestMintQuoteState(t *testing.T) { } // test quote state after paying invoice - quoteStateResponse, err = testMint.GetMintQuoteState(testutils.BOLT11_METHOD, mintQuoteResponse.Quote) + quoteStateResponse, err = testMint.GetMintQuoteState(testutils.BOLT11_METHOD, mintQuoteResponse.Id) if err != nil { t.Fatalf("unexpected error getting quote state: %v", err) } - if !quoteStateResponse.Paid { - t.Fatalf("expected quote.Paid '%v' but got '%v' instead", true, quoteStateResponse.Paid) - } if quoteStateResponse.State != nut04.Paid { t.Fatalf("expected quote state '%v' but got '%v' instead", nut04.Paid.String(), quoteStateResponse.State.String()) } @@ -174,19 +169,16 @@ func TestMintQuoteState(t *testing.T) { blindedMessages, _, _, err := testutils.CreateBlindedMessages(mintAmount, keyset) // mint tokens - _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Quote, blindedMessages) + _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Id, blindedMessages) if err != nil { t.Fatalf("got unexpected error minting tokens: %v", err) } // test quote state after minting tokens - quoteStateResponse, err = testMint.GetMintQuoteState(testutils.BOLT11_METHOD, mintQuoteResponse.Quote) + quoteStateResponse, err = testMint.GetMintQuoteState(testutils.BOLT11_METHOD, mintQuoteResponse.Id) if err != nil { t.Fatalf("unexpected error getting quote state: %v", err) } - if !quoteStateResponse.Paid { - t.Fatalf("expected quote.Paid '%v' but got '%v' instead", true, quoteStateResponse.Paid) - } if quoteStateResponse.State != nut04.Issued { t.Fatalf("expected quote state '%v' but got '%v' instead", nut04.Issued.String(), quoteStateResponse.State.String()) } @@ -200,7 +192,7 @@ func TestMintTokens(t *testing.T) { t.Fatalf("error requesting mint quote: %v", err) } - var keyset crypto.Keyset + var keyset crypto.MintKeyset for _, k := range testMint.ActiveKeysets { keyset = k break @@ -209,20 +201,20 @@ func TestMintTokens(t *testing.T) { blindedMessages, _, _, err := testutils.CreateBlindedMessages(mintAmount, keyset) // test without paying invoice - _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Quote, blindedMessages) + _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Id, blindedMessages) if !errors.Is(err, cashu.InvoiceNotPaidErr) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.InvoiceNotPaidErr, err) } // test invalid quote _, err = testMint.MintTokens(testutils.BOLT11_METHOD, "mintquote1234", blindedMessages) - if !errors.Is(err, cashu.InvoiceNotExistErr) { - t.Fatalf("expected error '%v' but got '%v' instead", cashu.InvoiceNotExistErr, err) + if !errors.Is(err, cashu.QuoteNotExistErr) { + t.Fatalf("expected error '%v' but got '%v' instead", cashu.QuoteNotExistErr, err) } //pay invoice sendPaymentRequest := lnrpc.SendRequest{ - PaymentRequest: mintQuoteResponse.Request, + PaymentRequest: mintQuoteResponse.PaymentRequest, } response, _ := lnd2.Client.SendPaymentSync(ctx, &sendPaymentRequest) if len(response.PaymentError) > 0 { @@ -231,26 +223,26 @@ func TestMintTokens(t *testing.T) { // test with blinded messages over request mint amount overBlindedMessages, _, _, err := testutils.CreateBlindedMessages(mintAmount+100, keyset) - _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Quote, overBlindedMessages) + _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Id, overBlindedMessages) if !errors.Is(err, cashu.OutputsOverInvoiceErr) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.OutputsOverInvoiceErr, err) } // test with invalid keyset in blinded messages - invalidKeyset := crypto.GenerateKeyset("seed", "path", 0) - invalidKeysetMessages, _, _, err := testutils.CreateBlindedMessages(mintAmount, *invalidKeyset) - _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Quote, invalidKeysetMessages) + invalidKeyset := crypto.MintKeyset{Id: "0192384aa"} + invalidKeysetMessages, _, _, err := testutils.CreateBlindedMessages(mintAmount, invalidKeyset) + _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Id, invalidKeysetMessages) if !errors.Is(err, cashu.InvalidSignatureRequest) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.InvalidSignatureRequest, err) } - _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Quote, blindedMessages) + _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Id, blindedMessages) if err != nil { t.Fatalf("got unexpected error minting tokens: %v", err) } // test already minted tokens - _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Quote, blindedMessages) + _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Id, blindedMessages) if !errors.Is(err, cashu.InvoiceTokensIssuedErr) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.InvoiceTokensIssuedErr, err) } @@ -263,7 +255,7 @@ func TestSwap(t *testing.T) { t.Fatalf("error generating valid proofs: %v", err) } - var keyset crypto.Keyset + var keyset crypto.MintKeyset for _, k := range testMint.ActiveKeysets { keyset = k break @@ -291,7 +283,7 @@ func TestSwap(t *testing.T) { // mint with fees mintFeesPath := filepath.Join(".", "mintfees") - mintFees, err := testutils.CreateTestMint(lnd1, "secretkey2", mintFeesPath, 100) + mintFees, err := testutils.CreateTestMint(lnd1, mintFeesPath, dbMigrationPath, 100) if err != nil { t.Fatal(err) } @@ -392,9 +384,6 @@ func TestMeltQuoteState(t *testing.T) { if err != nil { t.Fatalf("unexpected error getting melt quote state: %v", err) } - if meltQuote.Paid { - t.Fatalf("expected quote.Paid '%v' but got '%v' instead", false, meltQuote.Paid) - } if meltQuote.State != nut05.Unpaid { t.Fatalf("expected quote state '%v' but got '%v' instead", nut05.Unpaid.String(), meltQuote.State.String()) } @@ -405,7 +394,7 @@ func TestMeltQuoteState(t *testing.T) { t.Fatalf("error generating valid proofs: %v", err) } - melt, err := testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, validProofs) + _, err = testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, validProofs) if err != nil { t.Fatalf("got unexpected error in melt: %v", err) } @@ -414,9 +403,6 @@ func TestMeltQuoteState(t *testing.T) { if err != nil { t.Fatalf("unexpected error getting melt quote state: %v", err) } - if !melt.Paid { - t.Fatal("got unexpected unpaid melt quote") - } if meltQuote.State != nut05.Paid { t.Fatalf("expected quote state '%v' but got '%v' instead", nut05.Paid.String(), meltQuote.State.String()) } @@ -472,9 +458,6 @@ func TestMelt(t *testing.T) { if melt.State != nut05.Paid { t.Fatal("got unexpected unpaid melt quote") } - if !melt.Paid { - t.Fatal("got unexpected unpaid melt quote") - } // test already used proofs _, err = testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, validProofs) @@ -484,7 +467,7 @@ func TestMelt(t *testing.T) { // mint with fees mintFeesPath := filepath.Join(".", "mintfeesmelt") - mintFees, err := testutils.CreateTestMint(lnd1, "secretkey2", mintFeesPath, 100) + mintFees, err := testutils.CreateTestMint(lnd1, mintFeesPath, dbMigrationPath, 100) if err != nil { t.Fatal(err) } diff --git a/mint/server.go b/mint/server.go index 0e7baa4..be8d674 100644 --- a/mint/server.go +++ b/mint/server.go @@ -185,7 +185,7 @@ func (ms *MintServer) getKeysetById(rw http.ResponseWriter, req *http.Request) { return } - getKeysResponse := buildKeysResponse(map[string]crypto.Keyset{ks.Id: ks}) + getKeysResponse := buildKeysResponse(map[string]crypto.MintKeyset{ks.Id: ks}) jsonRes, err := json.Marshal(getKeysResponse) if err != nil { ms.writeErr(rw, req, cashu.StandardErr) @@ -206,20 +206,30 @@ func (ms *MintServer) mintRequest(rw http.ResponseWriter, req *http.Request) { return } - reqMintResponse, err := ms.mint.RequestMintQuote(method, mintReq.Amount, mintReq.Unit) + mintQuote, err := ms.mint.RequestMintQuote(method, mintReq.Amount, mintReq.Unit) if err != nil { cashuErr, ok := err.(*cashu.Error) - // note: RequestMintQuote will return err from lightning backend if invoice - // generation fails. Log that err from backend but return generic response to request - if ok && cashuErr.Code == cashu.InvoiceErrCode { - ms.writeErr(rw, req, cashu.StandardErr, cashuErr.Error()) - return + // note: if there was internal error from lightning backend generating invoice + // or error from db, log that error but return generic response + if ok { + if cashuErr.Code == cashu.InvoiceErrCode || cashuErr.Code == cashu.DBErrorCode { + ms.writeErr(rw, req, cashu.StandardErr, cashuErr.Error()) + return + } } ms.writeErr(rw, req, err) return } - jsonRes, err := json.Marshal(&reqMintResponse) + mintQuoteResponse := nut04.PostMintQuoteBolt11Response{ + Quote: mintQuote.Id, + Request: mintQuote.PaymentRequest, + State: mintQuote.State, + Paid: false, + Expiry: mintQuote.Expiry, + } + + jsonRes, err := json.Marshal(&mintQuoteResponse) if err != nil { ms.writeErr(rw, req, cashu.StandardErr) return @@ -234,18 +244,31 @@ func (ms *MintServer) mintQuoteState(rw http.ResponseWriter, req *http.Request) method := vars["method"] quoteId := vars["quote_id"] - mintQuoteStateResponse, err := ms.mint.GetMintQuoteState(method, quoteId) + mintQuote, err := ms.mint.GetMintQuoteState(method, quoteId) if err != nil { - // if error is from lnd, log it but throw generic response cashuErr, ok := err.(*cashu.Error) - if ok && cashuErr.Code == cashu.InvoiceErrCode { - ms.writeErr(rw, req, cashu.StandardErr, cashuErr.Error()) - return + // note: if there was internal error from lightning backend + // or error from db, log that error but return generic response + if ok { + if cashuErr.Code == cashu.InvoiceErrCode || cashuErr.Code == cashu.DBErrorCode { + ms.writeErr(rw, req, cashu.StandardErr, cashuErr.Error()) + return + } } ms.writeErr(rw, req, err) return } + + paid := mintQuote.State == nut04.Paid || mintQuote.State == nut04.Issued + mintQuoteStateResponse := nut04.PostMintQuoteBolt11Response{ + Quote: mintQuote.Id, + Request: mintQuote.PaymentRequest, + State: mintQuote.State, + Paid: paid, // DEPRECATED: remove after wallets have upgraded + Expiry: mintQuote.Expiry, + } + jsonRes, err := json.Marshal(&mintQuoteStateResponse) if err != nil { ms.writeErr(rw, req, cashu.StandardErr) @@ -268,11 +291,14 @@ func (ms *MintServer) mintTokensRequest(rw http.ResponseWriter, req *http.Reques blindedSignatures, err := ms.mint.MintTokens(method, mintReq.Quote, mintReq.Outputs) if err != nil { - // if error is from lnd, log it but throw generic response cashuErr, ok := err.(*cashu.Error) - if ok && cashuErr.Code == cashu.InvoiceErrCode { - ms.writeErr(rw, req, cashu.StandardErr, cashuErr.Error()) - return + // note: if there was internal error from lightning backend + // or error from db, log that error but return generic response + if ok { + if cashuErr.Code == cashu.InvoiceErrCode || cashuErr.Code == cashu.DBErrorCode { + ms.writeErr(rw, req, cashu.StandardErr, cashuErr.Error()) + return + } } ms.writeErr(rw, req, err) @@ -298,6 +324,14 @@ func (ms *MintServer) swapRequest(rw http.ResponseWriter, req *http.Request) { blindedSignatures, err := ms.mint.Swap(swapReq.Inputs, swapReq.Outputs) if err != nil { + cashuErr, ok := err.(*cashu.Error) + // note: if there was internal error from db + // log that error but return generic response + if ok && cashuErr.Code == cashu.DBErrorCode { + ms.writeErr(rw, req, cashu.StandardErr, cashuErr.Error()) + return + } + ms.writeErr(rw, req, err) return } @@ -324,20 +358,28 @@ func (ms *MintServer) meltQuoteRequest(rw http.ResponseWriter, req *http.Request meltQuote, err := ms.mint.MeltRequest(method, meltRequest.Request, meltRequest.Unit) if err != nil { + cashuErr, ok := err.(*cashu.Error) + // note: if there was internal error from db + // log that error but return generic response + if ok && cashuErr.Code == cashu.DBErrorCode { + ms.writeErr(rw, req, cashu.StandardErr, cashuErr.Error()) + return + } + ms.writeErr(rw, req, err) return } - quoteResponse := &nut05.PostMeltQuoteBolt11Response{ + meltQuoteResponse := &nut05.PostMeltQuoteBolt11Response{ Quote: meltQuote.Id, Amount: meltQuote.Amount, FeeReserve: meltQuote.FeeReserve, State: meltQuote.State, - Paid: meltQuote.Paid, + Paid: false, Expiry: meltQuote.Expiry, } - jsonRes, err := json.Marshal(quoteResponse) + jsonRes, err := json.Marshal(meltQuoteResponse) if err != nil { ms.writeErr(rw, req, cashu.StandardErr) return @@ -357,12 +399,13 @@ func (ms *MintServer) meltQuoteState(rw http.ResponseWriter, req *http.Request) return } + paid := meltQuote.State == nut05.Paid quoteState := &nut05.PostMeltQuoteBolt11Response{ Quote: meltQuote.Id, Amount: meltQuote.Amount, FeeReserve: meltQuote.FeeReserve, State: meltQuote.State, - Paid: meltQuote.Paid, + Paid: paid, Expiry: meltQuote.Expiry, Preimage: meltQuote.Preimage, } @@ -390,21 +433,29 @@ func (ms *MintServer) meltTokens(rw http.ResponseWriter, req *http.Request) { meltQuote, err := ms.mint.MeltTokens(method, meltTokensRequest.Quote, meltTokensRequest.Inputs) if err != nil { cashuErr, ok := err.(*cashu.Error) - if ok && cashuErr.Code == cashu.InvoiceErrCode { - responseError := cashu.BuildCashuError("unable to send payment", cashu.InvoiceErrCode) - ms.writeErr(rw, req, responseError, cashuErr.Error()) - return + // note: if there was internal error from lightning backend + // or error from db, log that error but return generic response + if ok { + if cashuErr.Code == cashu.InvoiceErrCode { + responseError := cashu.BuildCashuError("unable to send payment", cashu.InvoiceErrCode) + ms.writeErr(rw, req, responseError, cashuErr.Error()) + return + } else if cashuErr.Code == cashu.DBErrorCode { + ms.writeErr(rw, req, cashu.StandardErr, cashuErr.Error()) + return + } } ms.writeErr(rw, req, err) return } + paid := meltQuote.State == nut05.Paid meltQuoteResponse := &nut05.PostMeltQuoteBolt11Response{ Quote: meltQuote.Id, Amount: meltQuote.Amount, FeeReserve: meltQuote.FeeReserve, State: meltQuote.State, - Paid: meltQuote.Paid, + Paid: paid, Expiry: meltQuote.Expiry, Preimage: meltQuote.Preimage, } @@ -426,7 +477,7 @@ func (ms *MintServer) mintInfo(rw http.ResponseWriter, req *http.Request) { ms.writeResponse(rw, req, jsonRes, "returning mint info") } -func buildKeysResponse(keysets map[string]crypto.Keyset) nut01.GetKeysResponse { +func buildKeysResponse(keysets map[string]crypto.MintKeyset) nut01.GetKeysResponse { keysResponse := nut01.GetKeysResponse{} for _, keyset := range keysets { diff --git a/mint/storage/sqlite/migrations/000001_init.down.sql b/mint/storage/sqlite/migrations/000001_init.down.sql new file mode 100644 index 0000000..a3df960 --- /dev/null +++ b/mint/storage/sqlite/migrations/000001_init.down.sql @@ -0,0 +1,7 @@ + +DROP TABLE IF EXISTS seed; +DROP TABLE IF EXISTS keysets; +DROP TABLE IF EXISTS proofs; +DROP TABLE IF EXISTS mint_quotes; +DROP TABLE IF EXISTS melt_quotes; + diff --git a/mint/storage/sqlite/migrations/000001_init.up.sql b/mint/storage/sqlite/migrations/000001_init.up.sql new file mode 100644 index 0000000..b2fcfde --- /dev/null +++ b/mint/storage/sqlite/migrations/000001_init.up.sql @@ -0,0 +1,49 @@ + +CREATE TABLE IF NOT EXISTS seed ( + id TEXT NOT NULL PRIMARY KEY, + seed TEXT +); + +CREATE TABLE IF NOT EXISTS keysets ( + id TEXT NOT NULL PRIMARY KEY, + unit TEXT NOT NULL, + active BOOLEAN NOT NULL, + seed TEXT NOT NULL, + derivation_path_idx INTEGER NOT NULL, + input_fee_ppk INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS proofs ( + y TEXT PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + secret TEXT NOT NULL UNIQUE, + c TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_proofs_y ON proofs(y); + +CREATE TABLE IF NOT EXISTS mint_quotes ( + id TEXT PRIMARY KEY, + payment_request TEXT NOT NULL, + payment_hash TEXT, + amount INTEGER NOT NULL, + state TEXT NOT NULL, + expiry INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_mint_quotes_id ON mint_quotes(id); + +CREATE TABLE IF NOT EXISTS melt_quotes ( + id TEXT NOT NULL PRIMARY KEY, + request TEXT NOT NULL, + payment_hash TEXT, + amount INTEGER NOT NULL, + fee_reserve INTEGER NOT NULL, + state TEXT NOT NULL, + expiry INTEGER, + preimage TEXT +); + +CREATE INDEX IF NOT EXISTS idx_melt_quotes_id ON melt_quotes(id); + diff --git a/mint/storage/sqlite/sqlite.go b/mint/storage/sqlite/sqlite.go new file mode 100644 index 0000000..52c2397 --- /dev/null +++ b/mint/storage/sqlite/sqlite.go @@ -0,0 +1,306 @@ +package sqlite + +import ( + "database/sql" + "encoding/hex" + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut04" + "github.com/elnosh/gonuts/cashu/nuts/nut05" + "github.com/elnosh/gonuts/crypto" + "github.com/elnosh/gonuts/mint/storage" + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/sqlite3" + _ "github.com/golang-migrate/migrate/v4/source/file" + _ "github.com/mattn/go-sqlite3" +) + +type SQLiteDB struct { + db *sql.DB +} + +func InitSQLite(path, migrationPath string) (*SQLiteDB, error) { + dbpath := filepath.Join(path, "mint.sqlite.db") + db, err := sql.Open("sqlite3", dbpath) + if err != nil { + return nil, err + } + + m, err := migrate.New(fmt.Sprintf("file://%s", migrationPath), fmt.Sprintf("sqlite3://%s", dbpath)) + if err != nil { + return nil, err + } + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return nil, err + } + + if err := db.Ping(); err != nil { + return nil, err + } + + return &SQLiteDB{db: db}, nil +} + +func (sqlite *SQLiteDB) SaveSeed(seed []byte) error { + hexSeed := hex.EncodeToString(seed) + + _, err := sqlite.db.Exec(` + INSERT INTO seed (id, seed) VALUES (?, ?) + `, "id", hexSeed) + + return err +} + +func (sqlite *SQLiteDB) GetSeed() ([]byte, error) { + var hexSeed string + row := sqlite.db.QueryRow("SELECT seed FROM seed WHERE id = id") + err := row.Scan(&hexSeed) + if err != nil { + return nil, err + } + + seed, err := hex.DecodeString(hexSeed) + if err != nil { + return nil, err + } + + return seed, nil +} + +func (sqlite *SQLiteDB) SaveKeyset(keyset storage.DBKeyset) error { + _, err := sqlite.db.Exec(` + INSERT INTO keysets (id, unit, active, seed, derivation_path_idx, input_fee_ppk) VALUES (?, ?, ?, ?, ?, ?) + `, keyset.Id, keyset.Unit, keyset.Active, keyset.Seed, keyset.DerivationPathIdx, keyset.InputFeePpk) + + return err +} + +func (sqlite *SQLiteDB) GetKeysets() ([]storage.DBKeyset, error) { + keysets := []storage.DBKeyset{} + + rows, err := sqlite.db.Query("SELECT * FROM keysets") + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var keyset storage.DBKeyset + err := rows.Scan( + &keyset.Id, + &keyset.Unit, + &keyset.Active, + &keyset.Seed, + &keyset.DerivationPathIdx, + &keyset.InputFeePpk, + ) + if err != nil { + return nil, err + } + keysets = append(keysets, keyset) + } + + return keysets, nil +} + +func (sqlite *SQLiteDB) UpdateKeysetActive(id string, active bool) error { + result, err := sqlite.db.Exec("UPDATE keysets SET active = ? WHERE id = ?", active, id) + if err != nil { + return err + } + + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return errors.New("keyset was not updated") + } + return nil +} + +func (sqlite *SQLiteDB) SaveProofs(proofs cashu.Proofs) error { + tx, err := sqlite.db.Begin() + if err != nil { + return err + } + + stmt, err := tx.Prepare("INSERT INTO proofs (y, amount, keyset_id, secret, c) VALUES (?, ?, ?, ?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + for _, proof := range proofs { + Y, err := crypto.HashToCurve([]byte(proof.Secret)) + if err != nil { + return err + } + Yhex := hex.EncodeToString(Y.SerializeCompressed()) + + if _, err := stmt.Exec(Yhex, proof.Amount, proof.Id, proof.Secret, proof.C); err != nil { + tx.Rollback() + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + + return nil +} + +func (sqlite *SQLiteDB) GetProofsUsed(Ys []string) ([]cashu.Proof, error) { + proofs := []cashu.Proof{} + query := `SELECT amount, keyset_id, secret, c FROM proofs WHERE y in (?` + strings.Repeat(",?", len(Ys)-1) + `)` + + args := make([]any, len(Ys)) + for i, y := range Ys { + args[i] = y + } + + rows, err := sqlite.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var proof cashu.Proof + err := rows.Scan( + &proof.Amount, + &proof.Id, + &proof.Secret, + &proof.C, + ) + if err != nil { + return nil, err + } + + proofs = append(proofs, proof) + } + + return proofs, nil +} + +func (sqlite *SQLiteDB) SaveMintQuote(mintQuote storage.MintQuote) error { + _, err := sqlite.db.Exec( + `INSERT INTO mint_quotes (id, payment_request, payment_hash, amount, state, expiry) + VALUES (?, ?, ?, ?, ?, ?)`, + mintQuote.Id, + mintQuote.PaymentRequest, + mintQuote.PaymentHash, + mintQuote.Amount, + mintQuote.State.String(), + mintQuote.Expiry, + ) + + return err +} + +func (sqlite *SQLiteDB) GetMintQuote(quoteId string) (*storage.MintQuote, error) { + row := sqlite.db.QueryRow("SELECT * FROM mint_quotes WHERE id = ?", quoteId) + + var mintQuote storage.MintQuote + var state string + + err := row.Scan( + &mintQuote.Id, + &mintQuote.PaymentRequest, + &mintQuote.PaymentHash, + &mintQuote.Amount, + &state, + &mintQuote.Expiry, + ) + if err != nil { + return nil, err + } + mintQuote.State = nut04.StringToState(state) + + return &mintQuote, nil +} + +func (sqlite *SQLiteDB) UpdateMintQuoteState(quoteId string, state nut04.State) error { + updatedState := state.String() + result, err := sqlite.db.Exec("UPDATE mint_quotes SET state = ? WHERE id = ?", updatedState, quoteId) + if err != nil { + return err + } + + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return errors.New("mint quote was not updated") + } + return nil +} + +func (sqlite *SQLiteDB) SaveMeltQuote(meltQuote storage.MeltQuote) error { + _, err := sqlite.db.Exec(` + INSERT INTO melt_quotes + (id, request, payment_hash, amount, fee_reserve, state, expiry, preimage) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + meltQuote.Id, + meltQuote.InvoiceRequest, + meltQuote.PaymentHash, + meltQuote.Amount, + meltQuote.FeeReserve, + meltQuote.State.String(), + meltQuote.Expiry, + meltQuote.Preimage, + ) + + return err +} + +func (sqlite *SQLiteDB) GetMeltQuote(quoteId string) (*storage.MeltQuote, error) { + row := sqlite.db.QueryRow("SELECT * FROM melt_quotes WHERE id = ?", quoteId) + + var meltQuote storage.MeltQuote + var state string + + err := row.Scan( + &meltQuote.Id, + &meltQuote.InvoiceRequest, + &meltQuote.PaymentHash, + &meltQuote.Amount, + &meltQuote.FeeReserve, + &state, + &meltQuote.Expiry, + &meltQuote.Preimage, + ) + if err != nil { + return nil, err + } + meltQuote.State = nut05.StringToState(state) + + return &meltQuote, nil +} + +func (sqlite *SQLiteDB) UpdateMeltQuote(quoteId, preimage string, state nut05.State) error { + updatedState := state.String() + result, err := sqlite.db.Exec( + "UPDATE melt_quotes SET state = ?, preimage = ? WHERE id = ?", + updatedState, preimage, quoteId, + ) + if err != nil { + return err + } + + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return errors.New("melt quote was not updated") + } + return nil +} diff --git a/mint/storage/storage.go b/mint/storage/storage.go new file mode 100644 index 0000000..99f9315 --- /dev/null +++ b/mint/storage/storage.go @@ -0,0 +1,56 @@ +package storage + +import ( + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut04" + "github.com/elnosh/gonuts/cashu/nuts/nut05" +) + +type MintDB interface { + SaveSeed([]byte) error + GetSeed() ([]byte, error) + + SaveKeyset(DBKeyset) error + GetKeysets() ([]DBKeyset, error) + UpdateKeysetActive(keysetId string, active bool) error + + SaveProofs(cashu.Proofs) error + GetProofsUsed(Ys []string) ([]cashu.Proof, error) + + SaveMintQuote(MintQuote) error + GetMintQuote(string) (*MintQuote, error) + UpdateMintQuoteState(quoteId string, state nut04.State) error + + SaveMeltQuote(MeltQuote) error + GetMeltQuote(string) (*MeltQuote, error) + UpdateMeltQuote(quoteId string, preimage string, state nut05.State) error +} + +type DBKeyset struct { + Id string + Unit string + Active bool + Seed string + DerivationPathIdx uint32 + InputFeePpk uint +} + +type MintQuote struct { + Id string + Amount uint64 + PaymentRequest string + PaymentHash string + State nut04.State + Expiry uint64 +} + +type MeltQuote struct { + Id string + InvoiceRequest string + PaymentHash string + Amount uint64 + FeeReserve uint64 + State nut05.State + Expiry uint64 + Preimage string +} diff --git a/testutils/utils.go b/testutils/utils.go index 90180be..7fc1c6f 100644 --- a/testutils/utils.go +++ b/testutils/utils.go @@ -178,20 +178,20 @@ func FundCashuWallet(ctx context.Context, wallet *wallet.Wallet, lnd *btcdocker. func mintConfig( lnd *btcdocker.Lnd, - key string, port string, dbpath string, + dbMigrationPath string, inputFeePpk uint, ) (*mint.Config, error) { if err := os.MkdirAll(dbpath, 0750); err != nil { return nil, err } mintConfig := &mint.Config{ - PrivateKey: key, - DerivationPath: "0/0/0", - Port: port, - DBPath: dbpath, - InputFeePpk: inputFeePpk, + DerivationPathIdx: 0, + Port: port, + DBPath: dbpath, + DBMigrationPath: dbMigrationPath, + InputFeePpk: inputFeePpk, } nodeDir := lnd.LndDir @@ -216,11 +216,11 @@ func mintConfig( func CreateTestMint( lnd *btcdocker.Lnd, - key string, dbpath string, + dbMigrationPath string, inputFeePpk uint, ) (*mint.Mint, error) { - config, err := mintConfig(lnd, key, "", dbpath, inputFeePpk) + config, err := mintConfig(lnd, "", dbpath, dbMigrationPath, inputFeePpk) if err != nil { return nil, err } @@ -234,12 +234,12 @@ func CreateTestMint( func CreateTestMintServer( lnd *btcdocker.Lnd, - key string, port string, dbpath string, + dbMigrationPath string, inputFeePpk uint, ) (*mint.MintServer, error) { - config, err := mintConfig(lnd, key, port, dbpath, inputFeePpk) + config, err := mintConfig(lnd, port, dbpath, dbMigrationPath, inputFeePpk) if err != nil { return nil, err } @@ -257,7 +257,7 @@ func newBlindedMessage(id string, amount uint64, B_ *secp256k1.PublicKey) cashu. return cashu.BlindedMessage{Amount: amount, B_: B_str, Id: id} } -func CreateBlindedMessages(amount uint64, keyset crypto.Keyset) (cashu.BlindedMessages, []string, []*secp256k1.PrivateKey, error) { +func CreateBlindedMessages(amount uint64, keyset crypto.MintKeyset) (cashu.BlindedMessages, []string, []*secp256k1.PrivateKey, error) { splitAmounts := cashu.AmountSplit(amount) splitLen := len(splitAmounts) @@ -298,7 +298,7 @@ func CreateBlindedMessages(amount uint64, keyset crypto.Keyset) (cashu.BlindedMe } func ConstructProofs(blindedSignatures cashu.BlindedSignatures, - secrets []string, rs []*secp256k1.PrivateKey, keyset *crypto.Keyset) (cashu.Proofs, error) { + secrets []string, rs []*secp256k1.PrivateKey, keyset *crypto.MintKeyset) (cashu.Proofs, error) { if len(blindedSignatures) != len(secrets) || len(blindedSignatures) != len(rs) { return nil, errors.New("lengths do not match") @@ -338,7 +338,7 @@ func GetValidProofsForAmount(amount uint64, mint *mint.Mint, payer *btcdocker.Ln return nil, fmt.Errorf("error requesting mint quote: %v", err) } - var keyset crypto.Keyset + var keyset crypto.MintKeyset for _, k := range mint.ActiveKeysets { keyset = k break @@ -352,14 +352,14 @@ func GetValidProofsForAmount(amount uint64, mint *mint.Mint, payer *btcdocker.Ln ctx := context.Background() //pay invoice sendPaymentRequest := lnrpc.SendRequest{ - PaymentRequest: mintQuoteResponse.Request, + 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.Quote, blindedMessages) + blindedSignatures, err := mint.MintTokens(BOLT11_METHOD, mintQuoteResponse.Id, blindedMessages) if err != nil { return nil, fmt.Errorf("got unexpected error minting tokens: %v", err) } diff --git a/wallet/p2pk.go b/wallet/p2pk.go index 37f4d70..1e983f8 100644 --- a/wallet/p2pk.go +++ b/wallet/p2pk.go @@ -7,7 +7,7 @@ import ( // Derive key that wallet will use to receive locked ecash func DeriveP2PK(key *hdkeychain.ExtendedKey) (*btcec.PrivateKey, error) { - // m/129372 + // m/129372' purpose, err := key.Derive(hdkeychain.HardenedKeyStart + 129372) if err != nil { return nil, err diff --git a/wallet/storage/storage.go b/wallet/storage/storage.go index af60555..e7cdf59 100644 --- a/wallet/storage/storage.go +++ b/wallet/storage/storage.go @@ -53,5 +53,5 @@ type Invoice struct { Paid bool SettledAt int64 InvoiceAmount uint64 - QuoteExpiry int64 + QuoteExpiry uint64 } diff --git a/wallet/wallet_integration_test.go b/wallet/wallet_integration_test.go index 32776c0..ecfe8ca 100644 --- a/wallet/wallet_integration_test.go +++ b/wallet/wallet_integration_test.go @@ -20,10 +20,11 @@ import ( ) var ( - ctx context.Context - bitcoind *btcdocker.Bitcoind - lnd1 *btcdocker.Lnd - lnd2 *btcdocker.Lnd + ctx context.Context + bitcoind *btcdocker.Bitcoind + lnd1 *btcdocker.Lnd + lnd2 *btcdocker.Lnd + dbMigrationPath = "../mint/storage/sqlite/migrations" ) func TestMain(m *testing.M) { @@ -77,7 +78,7 @@ func testMain(m *testing.M) int { } testMintPath := filepath.Join(".", "testmint1") - testMint, err := testutils.CreateTestMintServer(lnd1, "secretkey1", "3338", testMintPath, 0) + testMint, err := testutils.CreateTestMintServer(lnd1, "3338", testMintPath, dbMigrationPath, 0) if err != nil { log.Println(err) return 1 @@ -88,7 +89,7 @@ func testMain(m *testing.M) int { go mint.StartMintServer(testMint) mintPath := filepath.Join(".", "testmintwithfees") - mintWithFees, err := testutils.CreateTestMintServer(lnd1, "mintsecretkey", "8888", mintPath, 100) + mintWithFees, err := testutils.CreateTestMintServer(lnd1, "8888", mintPath, dbMigrationPath, 100) if err != nil { log.Println(err) return 1 @@ -242,7 +243,7 @@ func TestReceive(t *testing.T) { } testMintPath := filepath.Join(".", "testmint2") - testMint, err := testutils.CreateTestMintServer(lnd2, "secretkey2", "3339", testMintPath, 0) + testMint, err := testutils.CreateTestMintServer(lnd2, "3339", testMintPath, dbMigrationPath, 0) if err != nil { t.Fatal(err) }