From c6747cdf44cb21ac34945e9bd0b7bbe687b16e46 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Mon, 27 Jan 2025 16:33:33 -0300 Subject: [PATCH] nip60: fixes, actual Cashu stuff and a wallet.Receive() method. --- go.mod | 4 + go.sum | 8 + nip60/client/client.go | 362 +++++++++++++++++++++++++++++++++++++++++ nip60/helpers.go | 264 ++++++++++++++++++++++++++++++ nip60/history.go | 6 +- nip60/lib.go | 10 +- nip60/nip60_test.go | 31 ++-- nip60/receive.go | 143 ++++++++++++++++ nip60/swap.go | 134 +++++++++++++++ nip60/token.go | 20 +-- nip60/wallet.go | 37 ++++- 11 files changed, 977 insertions(+), 42 deletions(-) create mode 100644 nip60/client/client.go create mode 100644 nip60/helpers.go create mode 100644 nip60/receive.go create mode 100644 nip60/swap.go diff --git a/go.mod b/go.mod index 78c87cb..fd532df 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 github.com/dgraph-io/badger/v4 v4.5.0 github.com/dgraph-io/ristretto v1.0.0 + github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3 github.com/fiatjaf/eventstore v0.15.0 github.com/fiatjaf/khatru v0.15.0 github.com/gomarkdown/markdown v0.0.0-20241205020045-f7e15b2f3e62 @@ -42,6 +43,7 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bep/debounce v1.2.1 // indirect + github.com/btcsuite/btcd v0.24.2 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -49,6 +51,7 @@ require ( github.com/dgraph-io/ristretto/v2 v2.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fasthttp/websocket v1.5.7 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/flatbuffers v24.12.23+incompatible // indirect github.com/google/uuid v1.6.0 // indirect @@ -72,6 +75,7 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect + github.com/x448/float16 v0.8.4 // indirect go.opencensus.io v0.24.0 // indirect golang.org/x/sys v0.29.0 // indirect google.golang.org/protobuf v1.36.2 // indirect diff --git a/go.sum b/go.sum index 93fadb4..8a742ee 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/bluekeyes/go-gitdiff v0.7.1/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iC github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= +github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= @@ -75,6 +77,8 @@ github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WA github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3 h1:k7evIqJ2BtFn191DgY/b03N2bMYA/iQwzr4f/uHYn20= +github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3/go.mod h1:vgZomh4YQk7R3w4ltZc0sHwCmndfHkuX6V4sga/8oNs= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -87,6 +91,8 @@ github.com/fiatjaf/khatru v0.15.0 h1:0aLWiTrdzoKD4WmW35GWL/Jsn4dACCUw325JKZg/AmI github.com/fiatjaf/khatru v0.15.0/go.mod h1:GBQJXZpitDatXF9RookRXcWB5zCJclCE4ufDK3jk80g= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -220,6 +226,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= golang.org/x/crypto v0.0.0-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/nip60/client/client.go b/nip60/client/client.go new file mode 100644 index 0000000..7d0436c --- /dev/null +++ b/nip60/client/client.go @@ -0,0 +1,362 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut01" + "github.com/elnosh/gonuts/cashu/nuts/nut02" + "github.com/elnosh/gonuts/cashu/nuts/nut03" + "github.com/elnosh/gonuts/cashu/nuts/nut04" + "github.com/elnosh/gonuts/cashu/nuts/nut05" + "github.com/elnosh/gonuts/cashu/nuts/nut06" + "github.com/elnosh/gonuts/cashu/nuts/nut07" + "github.com/elnosh/gonuts/cashu/nuts/nut09" +) + +func GetMintInfo(ctx context.Context, mintURL string) (*nut06.MintInfo, error) { + resp, err := httpGet(ctx, mintURL+"/v1/info") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var mintInfo nut06.MintInfo + if err := json.Unmarshal(body, &mintInfo); err != nil { + return nil, fmt.Errorf("error reading response from mint: %v", err) + } + + return &mintInfo, nil +} + +func GetActiveKeyset(ctx context.Context, mintURL string) (*nut01.Keyset, error) { + resp, err := httpGet(ctx, mintURL+"/v1/keys") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var keysetRes nut01.GetKeysResponse + if err := json.Unmarshal(body, &keysetRes); err != nil { + return nil, fmt.Errorf("error reading response from mint: %w", err) + } + + for _, keyset := range keysetRes.Keysets { + if keyset.Unit == cashu.Sat.String() { + return &keyset, nil + } + } + + return nil, fmt.Errorf("mint has no sat-denominated keyset? %v", keysetRes) +} + +func GetAllKeysets(ctx context.Context, mintURL string) ([]nut02.Keyset, error) { + resp, err := httpGet(ctx, mintURL+"/v1/keysets") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var keysetsRes nut02.GetKeysetsResponse + if err := json.Unmarshal(body, &keysetsRes); err != nil { + return nil, fmt.Errorf("error reading response from mint: %v", err) + } + + return keysetsRes.Keysets, nil +} + +func GetKeysetById(ctx context.Context, mintURL, id string) (map[uint64]string, error) { + resp, err := httpGet(ctx, mintURL+"/v1/keys/"+id) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var keysetRes nut01.GetKeysResponse + if err := json.Unmarshal(body, &keysetRes); err != nil || len(keysetRes.Keysets) != 1 { + return nil, fmt.Errorf("error reading response from mint: %w", err) + } + + return keysetRes.Keysets[0].Keys, nil +} + +func PostMintQuoteBolt11( + ctx context.Context, + mintURL string, + mintQuoteRequest nut04.PostMintQuoteBolt11Request, +) (*nut04.PostMintQuoteBolt11Response, error) { + resp, err := httpPost(ctx, mintURL+"/v1/mint/quote/bolt11", mintQuoteRequest) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var reqMintResponse nut04.PostMintQuoteBolt11Response + if err := json.Unmarshal(body, &reqMintResponse); err != nil { + return nil, fmt.Errorf("error reading response from mint: %w", err) + } + + return &reqMintResponse, nil +} + +func GetMintQuoteState(ctx context.Context, mintURL, quoteId string) (*nut04.PostMintQuoteBolt11Response, error) { + resp, err := httpGet(ctx, mintURL+"/v1/mint/quote/bolt11/"+quoteId) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var mintQuoteResponse nut04.PostMintQuoteBolt11Response + if err := json.Unmarshal(body, &mintQuoteResponse); err != nil { + return nil, fmt.Errorf("error reading response from mint: %w", err) + } + + return &mintQuoteResponse, nil +} + +func PostMintBolt11( + ctx context.Context, + mintURL string, + mintRequest nut04.PostMintBolt11Request, +) (*nut04.PostMintBolt11Response, error) { + resp, err := httpPost(ctx, mintURL+"/v1/mint/bolt11", mintRequest) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var reqMintResponse nut04.PostMintBolt11Response + if err := json.Unmarshal(body, &reqMintResponse); err != nil { + return nil, fmt.Errorf("error reading response from mint: %w", err) + } + + return &reqMintResponse, nil +} + +func PostSwap(ctx context.Context, mintURL string, swapRequest nut03.PostSwapRequest) (*nut03.PostSwapResponse, error) { + resp, err := httpPost(ctx, mintURL+"/v1/swap", swapRequest) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var swapResponse nut03.PostSwapResponse + if err := json.Unmarshal(body, &swapResponse); err != nil { + return nil, fmt.Errorf("error reading response from mint: %w", err) + } + + return &swapResponse, nil +} + +func PostMeltQuoteBolt11( + ctx context.Context, + mintURL string, + meltQuoteRequest nut05.PostMeltQuoteBolt11Request, +) (*nut05.PostMeltQuoteBolt11Response, error) { + resp, err := httpPost(ctx, mintURL+"/v1/melt/quote/bolt11", meltQuoteRequest) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var meltQuoteResponse nut05.PostMeltQuoteBolt11Response + if err := json.Unmarshal(body, &meltQuoteResponse); err != nil { + return nil, fmt.Errorf("error reading response from mint: %w", err) + } + + return &meltQuoteResponse, nil +} + +func GetMeltQuoteState(ctx context.Context, mintURL, quoteId string) (*nut05.PostMeltQuoteBolt11Response, error) { + resp, err := httpGet(ctx, mintURL+"/v1/melt/quote/bolt11/"+quoteId) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var meltQuoteResponse nut05.PostMeltQuoteBolt11Response + if err := json.Unmarshal(body, &meltQuoteResponse); err != nil { + return nil, fmt.Errorf("error reading response from mint: %w", err) + } + + return &meltQuoteResponse, nil +} + +func PostMeltBolt11( + ctx context.Context, + mintURL string, + meltRequest nut05.PostMeltBolt11Request, +) (*nut05.PostMeltQuoteBolt11Response, error) { + resp, err := httpPost(ctx, mintURL+"/v1/melt/bolt11", meltRequest) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var meltResponse nut05.PostMeltQuoteBolt11Response + if err := json.Unmarshal(body, &meltResponse); err != nil { + return nil, fmt.Errorf("error reading response from mint: %w", err) + } + + return &meltResponse, nil +} + +func PostCheckProofState( + ctx context.Context, + mintURL string, + stateRequest nut07.PostCheckStateRequest, +) (*nut07.PostCheckStateResponse, error) { + resp, err := httpPost(ctx, mintURL+"/v1/checkstate", stateRequest) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var stateResponse nut07.PostCheckStateResponse + if err := json.Unmarshal(body, &stateResponse); err != nil { + return nil, fmt.Errorf("error reading response from mint: %v", err) + } + + return &stateResponse, nil +} + +func PostRestore( + ctx context.Context, + mintURL string, + restoreRequest nut09.PostRestoreRequest, +) (*nut09.PostRestoreResponse, error) { + resp, err := httpPost(ctx, mintURL+"/v1/restore", restoreRequest) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var restoreResponse nut09.PostRestoreResponse + if err := json.Unmarshal(body, &restoreResponse); err != nil { + return nil, fmt.Errorf("error reading response from mint: %v", err) + } + + return &restoreResponse, nil +} + +func httpGet(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + return parse(resp) +} + +func httpPost(ctx context.Context, url string, data any) (*http.Response, error) { + r, w := io.Pipe() + json.NewEncoder(w).Encode(data) + + req, err := http.NewRequestWithContext(ctx, "POST", url, r) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + return parse(resp) +} + +func parse(response *http.Response) (*http.Response, error) { + if response.StatusCode == 400 { + var errResponse cashu.Error + err := json.NewDecoder(response.Body).Decode(&errResponse) + if err != nil { + return nil, fmt.Errorf("could not decode error response from mint: %v", err) + } + return nil, errResponse + } + + if response.StatusCode != 200 { + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("%s", body) + } + + return response, nil +} diff --git a/nip60/helpers.go b/nip60/helpers.go new file mode 100644 index 0000000..4822925 --- /dev/null +++ b/nip60/helpers.go @@ -0,0 +1,264 @@ +package nip60 + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "slices" + + "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/nut01" + "github.com/elnosh/gonuts/cashu/nuts/nut02" + "github.com/elnosh/gonuts/cashu/nuts/nut10" + "github.com/elnosh/gonuts/cashu/nuts/nut11" + "github.com/elnosh/gonuts/cashu/nuts/nut12" + "github.com/elnosh/gonuts/crypto" +) + +func calculateFee(inputs cashu.Proofs, keysets []nut02.Keyset) uint { + var n uint = 0 +next: + for _, proof := range inputs { + for _, ks := range keysets { + if ks.Id == proof.Id { + n += ks.InputFeePpk + continue next + } + } + + panic(fmt.Errorf("spending a proof we don't have the keyset for? %v // %v", proof, keysets)) + } + return (n + 999) / 1000 +} + +// returns blinded messages, secrets - [][]byte, and list of r +func createBlindedMessages( + splitAmounts []uint64, + keysetId string, +) (cashu.BlindedMessages, []string, []*secp256k1.PrivateKey, error) { + splitLen := len(splitAmounts) + blindedMessages := make(cashu.BlindedMessages, splitLen) + secrets := make([]string, splitLen) + rs := make([]*secp256k1.PrivateKey, splitLen) + + for i, amt := range splitAmounts { + var secret string + var r *secp256k1.PrivateKey + secret, r, err := generateRandomSecret() + 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] = secret + rs[i] = r + } + + return blindedMessages, secrets, rs, nil +} + +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 splitWalletTarget(proofs cashu.Proofs, amountToSplit uint64, mint string) []uint64 { + target := 3 + + // amounts that are in wallet + amountsInWallet := make([]uint64, len(proofs)) + for i, proof := range proofs { + amountsInWallet[i] = proof.Amount + } + slices.Sort(amountsInWallet) + + allPossibleAmounts := make([]uint64, crypto.MAX_ORDER) + for i := 0; i < crypto.MAX_ORDER; i++ { + amount := uint64(math.Pow(2, float64(i))) + allPossibleAmounts[i] = amount + } + + // based on amounts that are already in the wallet + // define what amounts wanted to reach target + var neededAmounts []uint64 + for _, amount := range allPossibleAmounts { + count := cashu.Count(amountsInWallet, amount) + timesToAdd := cashu.Max(0, uint64(target)-uint64(count)) + for i := 0; i < int(timesToAdd); i++ { + neededAmounts = append(neededAmounts, amount) + } + } + slices.Sort(neededAmounts) + + // fill in based on the needed amounts + // that are below the amount passed (amountToSplit) + var amounts []uint64 + var amountsSum uint64 = 0 + for amountsSum < amountToSplit { + if len(neededAmounts) > 0 { + if amountsSum+neededAmounts[0] > amountToSplit { + break + } + amounts = append(amounts, neededAmounts[0]) + amountsSum += neededAmounts[0] + neededAmounts = slices.Delete(neededAmounts, 0, 1) + } else { + break + } + } + + remainingAmount := amountToSplit - amountsSum + if remainingAmount > 0 { + amounts = append(amounts, cashu.AmountSplit(remainingAmount)...) + } + slices.Sort(amounts) + + return amounts +} + +func signInput( + privateKey *btcec.PrivateKey, + publicKey *btcec.PublicKey, + proof cashu.Proof, + nut10Secret nut10.WellKnownSecret, +) (string, error) { + hash := sha256.Sum256([]byte(proof.Secret)) + signature, err := schnorr.Sign(privateKey, hash[:]) + if err != nil { + return "", fmt.Errorf("failed to sign: %w", err) + } + witness, _ := json.Marshal(nut11.P2PKWitness{ + Signatures: []string{hex.EncodeToString(signature.Serialize())}, + }) + return string(witness), nil +} + +func signOutput( + privateKey *btcec.PrivateKey, + output cashu.BlindedMessage, +) (string, error) { + msg, _ := hex.DecodeString(output.B_) + hash := sha256.Sum256(msg) + signature, err := schnorr.Sign(privateKey, hash[:]) + if err != nil { + return "", fmt.Errorf("failed to sign: %w", err) + } + witness, _ := json.Marshal(nut11.P2PKWitness{ + Signatures: []string{hex.EncodeToString(signature.Serialize())}, + }) + return string(witness), nil +} + +// constructProofs unblinds the blindedSignatures and returns the proofs +func constructProofs( + blindedSignatures cashu.BlindedSignatures, + blindedMessages cashu.BlindedMessages, + secrets []string, + rs []*secp256k1.PrivateKey, + keys map[uint64]*btcec.PublicKey, +) (cashu.Proofs, error) { + sigsLenght := len(blindedSignatures) + if sigsLenght != len(secrets) || sigsLenght != len(rs) { + return nil, errors.New("lengths do not match") + } + + proofs := make(cashu.Proofs, len(blindedSignatures)) + for i, blindedSignature := range blindedSignatures { + pubkey, ok := keys[blindedSignature.Amount] + if !ok { + return nil, errors.New("key not found") + } + + var dleq *cashu.DLEQProof + // verify DLEQ if present + if blindedSignature.DLEQ != nil { + if !nut12.VerifyBlindSignatureDLEQ( + *blindedSignature.DLEQ, + pubkey, + blindedMessages[i].B_, + blindedSignature.C_, + ) { + return nil, errors.New("got blinded signature with invalid DLEQ proof") + } else { + dleq = &cashu.DLEQProof{ + E: blindedSignature.DLEQ.E, + S: blindedSignature.DLEQ.S, + R: hex.EncodeToString(rs[i].Serialize()), + } + } + } + + C, err := unblindSignature(blindedSignature.C_, rs[i], pubkey) + if err != nil { + return nil, err + } + + proof := cashu.Proof{ + Amount: blindedSignature.Amount, + Secret: secrets[i], + C: C, + Id: blindedSignature.Id, + DLEQ: dleq, + } + proofs[i] = proof + } + + return proofs, nil +} + +func unblindSignature(C_str string, r *secp256k1.PrivateKey, key *secp256k1.PublicKey) ( + string, + error, +) { + C_bytes, err := hex.DecodeString(C_str) + if err != nil { + return "", err + } + C_, err := secp256k1.ParsePubKey(C_bytes) + if err != nil { + return "", err + } + + C := crypto.UnblindSignature(C_, r, key) + Cstr := hex.EncodeToString(C.SerializeCompressed()) + return Cstr, nil +} + +func parseKeysetKeys(keys nut01.KeysMap) (map[uint64]*btcec.PublicKey, error) { + parsedKeys := make(map[uint64]*btcec.PublicKey) + for amount, pkh := range keys { + pkb, err := hex.DecodeString(pkh) + if err != nil { + return nil, err + } + pubkey, err := btcec.ParsePubKey(pkb) + if err != nil { + return nil, err + } + parsedKeys[amount] = pubkey + } + return parsedKeys, nil +} diff --git a/nip60/history.go b/nip60/history.go index c9e231d..de272c4 100644 --- a/nip60/history.go +++ b/nip60/history.go @@ -11,7 +11,7 @@ import ( type HistoryEntry struct { In bool // in = received, out = sent - Amount uint32 + Amount uint64 tokenEventIDs []string nutZaps []bool @@ -120,11 +120,11 @@ func (h *HistoryEntry) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Eve if tag[2] != "sat" { return fmt.Errorf("only 'sat' wallets are supported") } - v, err := strconv.ParseUint(tag[1], 10, 32) + v, err := strconv.ParseUint(tag[1], 10, 64) if err != nil { return fmt.Errorf("invalid 'amount' %s: %w", tag[1], err) } - h.Amount = uint32(v) + h.Amount = v case "e": essential++ if len(tag) < 4 { diff --git a/nip60/lib.go b/nip60/lib.go index 15a3b9f..32056cf 100644 --- a/nip60/lib.go +++ b/nip60/lib.go @@ -17,7 +17,15 @@ type WalletStash struct { pendingHistory map[string][]HistoryEntry // history entries not yet assigned to a wallet } -func LoadWallets( +func NewStash() *WalletStash { + return &WalletStash{ + wallets: make(map[string]*Wallet, 1), + pendingTokens: make(map[string][]Token), + pendingHistory: make(map[string][]HistoryEntry), + } +} + +func LoadStash( ctx context.Context, kr nostr.Keyer, events <-chan *nostr.Event, diff --git a/nip60/nip60_test.go b/nip60/nip60_test.go index 199a646..97cc36e 100644 --- a/nip60/nip60_test.go +++ b/nip60/nip60_test.go @@ -2,10 +2,11 @@ package nip60 import ( "context" - "fmt" "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/elnosh/gonuts/cashu" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/keyer" "github.com/stretchr/testify/require" @@ -17,26 +18,27 @@ func TestWalletRoundtrip(t *testing.T) { require.NoError(t, err) // create initial wallets with arbitrary data + sk1, _ := btcec.NewPrivateKey() wallet1 := Wallet{ Identifier: "wallet1", Name: "My First Wallet", Description: "Test wallet number one", - PrivateKey: "secret123", + PrivateKey: sk1, Relays: []string{"wss://relay1.example.com", "wss://relay2.example.com"}, Mints: []string{"https://mint1.example.com"}, Tokens: []Token{ { Mint: "https://mint1.example.com", - Proofs: []Proof{ - {ID: "proof1", Amount: 100, Secret: "secret1", C: "c1"}, - {ID: "proof2", Amount: 200, Secret: "secret2", C: "c2"}, + Proofs: []cashu.Proof{ + {Id: "proof1", Amount: 100, Secret: "secret1", C: "c1"}, + {Id: "proof2", Amount: 200, Secret: "secret2", C: "c2"}, }, mintedAt: nostr.Now(), }, { Mint: "https://mint2.example.com", - Proofs: []Proof{ - {ID: "proof3", Amount: 500, Secret: "secret3", C: "c3"}, + Proofs: []cashu.Proof{ + {Id: "proof3", Amount: 500, Secret: "secret3", C: "c3"}, }, mintedAt: nostr.Now(), }, @@ -55,18 +57,19 @@ func TestWalletRoundtrip(t *testing.T) { }, } + sk2, _ := btcec.NewPrivateKey() wallet2 := Wallet{ Identifier: "wallet2", Name: "Second Wallet", Description: "Test wallet number two", - PrivateKey: "secret456", + PrivateKey: sk2, Relays: []string{"wss://relay3.example.com"}, Mints: []string{"https://mint2.example.com"}, Tokens: []Token{ { Mint: "https://mint2.example.com", - Proofs: []Proof{ - {ID: "proof3", Amount: 500, Secret: "secret3", C: "c3"}, + Proofs: []cashu.Proof{ + {Id: "proof3", Amount: 500, Secret: "secret3", C: "c3"}, }, mintedAt: nostr.Now(), }, @@ -125,13 +128,15 @@ func TestWalletRoundtrip(t *testing.T) { // load wallets from events errorChan := make(chan error) - walletStash := LoadWallets(ctx, kr, eventChan, errorChan) + walletStash := LoadStash(ctx, kr, eventChan, errorChan) var errorChanErr error go func() { for { errorChanErr = <-errorChan - fmt.Println(errorChanErr) + if errorChanErr != nil { + return + } } }() @@ -143,6 +148,7 @@ func TestWalletRoundtrip(t *testing.T) { loadedWallet1 := walletStash.wallets[wallet1.Identifier] require.Equal(t, wallet1.Name, loadedWallet1.Name) require.Equal(t, wallet1.Description, loadedWallet1.Description) + require.Equal(t, wallet1.Mints, loadedWallet1.Mints) require.Equal(t, wallet1.PrivateKey, loadedWallet1.PrivateKey) require.Len(t, loadedWallet1.Tokens, len(wallet1.Tokens)) require.Len(t, loadedWallet1.History, len(wallet1.History)) @@ -150,6 +156,7 @@ func TestWalletRoundtrip(t *testing.T) { loadedWallet2 := walletStash.wallets[wallet2.Identifier] require.Equal(t, wallet2.Name, loadedWallet2.Name) require.Equal(t, wallet2.Description, loadedWallet2.Description) + require.Equal(t, wallet2.Mints, loadedWallet2.Mints) require.Equal(t, wallet2.PrivateKey, loadedWallet2.PrivateKey) require.Len(t, loadedWallet2.Tokens, len(wallet2.Tokens)) require.Len(t, loadedWallet2.History, len(wallet2.History)) diff --git a/nip60/receive.go b/nip60/receive.go new file mode 100644 index 0000000..4792df9 --- /dev/null +++ b/nip60/receive.go @@ -0,0 +1,143 @@ +package nip60 + +import ( + "context" + "fmt" + "slices" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut02" + "github.com/elnosh/gonuts/cashu/nuts/nut03" + "github.com/elnosh/gonuts/cashu/nuts/nut10" + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip60/client" +) + +func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error { + token, err := cashu.DecodeToken(serializedToken) + if err != nil { + return err + } + + source := "http" + nostr.NormalizeURL(token.Mint())[2:] + swap := slices.Contains(w.Mints, source) + proofs := token.Proofs() + isp2pk := false + + for i, proof := range proofs { + if proof.Secret != "" { + nut10Secret, err := nut10.DeserializeSecret(proof.Secret) + if err != nil { + return fmt.Errorf("invalid nip10 secret at %d: %w", i, err) + } + switch nut10Secret.Kind { + case nut10.P2PK: + isp2pk = true + proofs[i].Witness, err = signInput(w.PrivateKey, w.PublicKey, proof, nut10Secret) + if err != nil { + return fmt.Errorf("failed to sign locked proof %d: %w", i, err) + } + case nut10.HTLC: + return fmt.Errorf("HTLC token not supported yet") + case nut10.AnyoneCanSpend: + // ok + } + } + } + + sourceKeysets, err := client.GetAllKeysets(ctx, source) + if err != nil { + return fmt.Errorf("failed to get %s keysets: %w", source, err) + } + var sourceActiveKeyset nut02.Keyset + var sourceActiveKeys map[uint64]*btcec.PublicKey + for _, keyset := range sourceKeysets { + if keyset.Unit == cashu.Sat.String() && keyset.Active { + sourceActiveKeyset = keyset + sourceActiveKeysHex, err := client.GetKeysetById(ctx, source, keyset.Id) + if err != nil { + return fmt.Errorf("failed to get keyset keys for %s: %w", keyset.Id, err) + } + sourceActiveKeys, err = parseKeysetKeys(sourceActiveKeysHex) + } + } + + // get new proofs + splits := make([]uint64, len(proofs)) + for i, p := range proofs { // TODO: do the fee stuff here because it won't always be free + splits[i] = p.Amount + } + + outputs, secrets, rs, err := createBlindedMessages(splits, sourceActiveKeyset.Id) + if err != nil { + return fmt.Errorf("failed to create blinded message: %w", err) + } + + if isp2pk { + for i, output := range outputs { + outputs[i].Witness, err = signOutput(w.PrivateKey, output) + if err != nil { + return fmt.Errorf("failed to sign output message %d: %w", i, err) + } + } + } + + req := nut03.PostSwapRequest{ + Inputs: proofs, + Outputs: outputs, + } + + res, err := client.PostSwap(ctx, source, req) + if err != nil { + return fmt.Errorf("failed to swap %s->%s: %w", source, w.Mints[0], err) + } + + newProofs, err := constructProofs(res.Signatures, req.Outputs, secrets, rs, sourceActiveKeys) + if err != nil { + return fmt.Errorf("failed to construct proofs: %w", err) + } + newMint := source + + // if we have to swap to our own mint we do it now by getting a bolt11 invoice from our mint + // and telling the current mint to pay it + if swap { + for _, targetMint := range w.Mints { + swappedProofs, err, tryAnother, needsManualAction := lightningMeltMint( + ctx, + newProofs, + source, + sourceKeysets, + targetMint, + ) + if err != nil { + if tryAnother { + continue + } + if needsManualAction { + return fmt.Errorf("failed to swap (needs manual action): %w", err) + } + + // if we get here that means we still have our proofs from the untrusted mint, so save those + goto saveproofs + } else { + // everything went well + newProofs = swappedProofs + newMint = targetMint + goto saveproofs + } + } + + // if we got here that means we ran out of our trusted mints to swap to, so save the untrusted proofs + goto saveproofs + } + +saveproofs: + w.Tokens = append(w.Tokens, Token{ + Mint: newMint, + Proofs: newProofs, + mintedAt: nostr.Now(), + }) + + return nil +} diff --git a/nip60/swap.go b/nip60/swap.go new file mode 100644 index 0000000..5acffe3 --- /dev/null +++ b/nip60/swap.go @@ -0,0 +1,134 @@ +package nip60 + +import ( + "context" + "fmt" + "time" + + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut02" + "github.com/elnosh/gonuts/cashu/nuts/nut04" + "github.com/elnosh/gonuts/cashu/nuts/nut05" + "github.com/nbd-wtf/go-nostr/nip60/client" +) + +// lightningMeltMint does the lightning dance of moving funds between mints +func lightningMeltMint( + ctx context.Context, + proofs cashu.Proofs, + from string, + fromKeysets []nut02.Keyset, + to string, +) (newProofs cashu.Proofs, err error, canTryWithAnotherTargetMint bool, manualActionRequired bool) { + // get active keyset of target mint + keyset, err := client.GetActiveKeyset(ctx, to) + if err != nil { + return nil, fmt.Errorf("failed to get keyset keys for %s: %w", to, err), true, false + } + + // unblind the signatures from the promises and build the proofs + keysetKeys, err := parseKeysetKeys(keyset.Keys) + if err != nil { + return nil, fmt.Errorf("target mint %s sent us an invalid keyset: %w", to, err), true, false + } + + // now we start the melt-mint process in multiple attempts + invoicePct := 0.99 + proofsAmount := proofs.Amount() + amount := float64(proofsAmount) * invoicePct + fee := uint64(calculateFee(proofs, fromKeysets)) + var meltQuote string + var mintQuote string + for range 10 { + // request _mint_ quote to the 'to' mint -- this will generate an invoice + mintResp, err := client.PostMintQuoteBolt11(ctx, to, nut04.PostMintQuoteBolt11Request{ + Amount: uint64(amount) - fee, + Unit: cashu.Sat.String(), + }) + if err != nil { + return nil, fmt.Errorf("error requesting mint quote from %s: %w", to, err), true, false + } + + // request _melt_ quote from the 'from' mint + // this melt will pay the invoice generated from the previous mint quote request + meltResp, err := client.PostMeltQuoteBolt11(ctx, from, nut05.PostMeltQuoteBolt11Request{ + Request: mintResp.Request, + Unit: cashu.Sat.String(), + }) + if err != nil { + return nil, fmt.Errorf("error requesting melt quote from %s: %w", from, err), false, false + } + + // if amount in proofs is less than amount asked from mint in melt request, + // lower the amount for mint request (because of lighting fees?) + if meltResp.Amount+meltResp.FeeReserve+fee > proofsAmount { + invoicePct -= 0.01 + amount *= invoicePct + } else { + meltQuote = meltResp.Quote + mintQuote = mintResp.Quote + goto meltworked + } + } + + return nil, fmt.Errorf("stop trying to do the melt because the mint part is too expensive"), true, false + +meltworked: + // request from mint to pay invoice from the mint quote request + _, err = client.PostMeltBolt11(ctx, from, nut05.PostMeltBolt11Request{ + Quote: meltQuote, + Inputs: proofs, + }) + if err != nil { + return nil, fmt.Errorf("error melting token: %v", err), false, true + } + + sleepTime := time.Millisecond * 200 + failures := 0 + for range 12 { + sleepTime *= 2 + time.Sleep(sleepTime) + + // check if the _mint_ invoice was paid + mintQuoteStatusResp, err := client.GetMintQuoteState(ctx, to, mintQuote) + if err != nil { + failures++ + if failures > 10 { + return nil, fmt.Errorf( + "target mint %s failed to answer to our mint quote checks (%s): %w; a manual fix is needed", + to, meltQuote, err, + ), false, true + } + } + + // if it wasn't paid try again + if mintQuoteStatusResp.State != nut04.Paid { + continue + } + + // if it got paid make proceed to get proofs + split := []uint64{1, 2, 3, 4} + blindedMessages, secrets, rs, err := createBlindedMessages(split, keyset.Id) + if err != nil { + return nil, fmt.Errorf("error creating blinded messages: %v", err), false, true + } + + // request mint to sign the blinded messages + mintResponse, err := client.PostMintBolt11(ctx, to, nut04.PostMintBolt11Request{ + Quote: mintQuote, + Outputs: blindedMessages, + }) + if err != nil { + return nil, fmt.Errorf("mint request to %s failed (%s): %w", to, mintQuote, err), false, true + } + + proofs, err := constructProofs(mintResponse.Signatures, blindedMessages, secrets, rs, keysetKeys) + if err != nil { + return nil, fmt.Errorf("error constructing proofs: %w", err), false, true + } + + return proofs, nil, false, false + } + + return nil, fmt.Errorf("we gave up waiting for the invoice at %s to be paid: %s", to, meltQuote), false, true +} diff --git a/nip60/token.go b/nip60/token.go index 3b7082c..bd9ad4f 100644 --- a/nip60/token.go +++ b/nip60/token.go @@ -5,32 +5,18 @@ import ( "encoding/json" "fmt" + "github.com/elnosh/gonuts/cashu" "github.com/nbd-wtf/go-nostr" ) type Token struct { - Mint string `json:"mint"` - Proofs []Proof `json:"proofs"` + Mint string `json:"mint"` + Proofs cashu.Proofs `json:"proofs"` mintedAt nostr.Timestamp event *nostr.Event } -type Proof struct { - ID string `json:"id"` - Amount uint32 `json:"amount"` - Secret string `json:"secret"` - C string `json:"C"` -} - -func (t Token) Amount() uint32 { - var sum uint32 - for _, p := range t.Proofs { - sum += p.Amount - } - return sum -} - func (t Token) toEvent(ctx context.Context, kr nostr.Keyer, walletId string, evt *nostr.Event) error { pk, err := kr.GetPublicKey(ctx) if err != nil { diff --git a/nip60/wallet.go b/nip60/wallet.go index b51dc37..d923073 100644 --- a/nip60/wallet.go +++ b/nip60/wallet.go @@ -2,10 +2,13 @@ package nip60 import ( "context" + "encoding/hex" "encoding/json" "fmt" "strconv" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/nbd-wtf/go-nostr" ) @@ -13,19 +16,20 @@ type Wallet struct { Identifier string Description string Name string - PrivateKey string + PrivateKey *btcec.PrivateKey + PublicKey *btcec.PublicKey Relays []string Mints []string Tokens []Token History []HistoryEntry - temporaryBalance uint32 + temporaryBalance uint64 } -func (w Wallet) Balance() uint32 { - var sum uint32 +func (w Wallet) Balance() uint64 { + var sum uint64 for _, token := range w.Tokens { - sum += token.Amount() + sum += token.Proofs.Amount() } return sum } @@ -44,7 +48,7 @@ func (w Wallet) ToPublishableEvents(ctx context.Context, kr nostr.Keyer, skipExi evt.Content, err = kr.Encrypt( ctx, - fmt.Sprintf(`[["balance","%d","sat"],["privkey","%s"]]`, w.Balance(), w.PrivateKey), + fmt.Sprintf(`[["balance","%d","sat"],["privkey","%x"]]`, w.Balance(), w.PrivateKey.Serialize()), pk, ) if err != nil { @@ -64,6 +68,9 @@ func (w Wallet) ToPublishableEvents(ctx context.Context, kr nostr.Keyer, skipExi for _, relay := range w.Relays { evt.Tags = append(evt.Tags, nostr.Tag{"relay", relay}) } + for _, mint := range w.Mints { + evt.Tags = append(evt.Tags, nostr.Tag{"mint", mint}) + } err = kr.SignEvent(ctx, &evt) if err != nil { @@ -141,6 +148,7 @@ func (w *Wallet) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) er } switch tag[0] { case "d": + essential++ w.Identifier = tag[1] case "name": w.Name = tag[1] @@ -154,9 +162,16 @@ func (w *Wallet) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) er case "relay": w.Relays = append(w.Relays, tag[1]) case "mint": + essential++ w.Mints = append(w.Mints, tag[1]) case "privkey": - w.PrivateKey = tag[1] + essential++ + skb, err := hex.DecodeString(tag[1]) + if err != nil { + return fmt.Errorf("failed to parse private key: %w", err) + } + w.PrivateKey = secp256k1.PrivKeyFromBytes(skb) + w.PublicKey = w.PrivateKey.PubKey() case "balance": if len(tag) < 3 { return fmt.Errorf("'balance' tag must have at least 3 items") @@ -164,13 +179,17 @@ func (w *Wallet) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) er if tag[2] != "sat" { return fmt.Errorf("only 'sat' wallets are supported") } - v, err := strconv.ParseUint(tag[1], 10, 32) + v, err := strconv.ParseUint(tag[1], 10, 64) if err != nil { return fmt.Errorf("invalid 'balance' %s: %w", tag[1], err) } - w.temporaryBalance = uint32(v) + w.temporaryBalance = v } } + if essential < 4 { + return fmt.Errorf("missing essential tags %s", evt) + } + return nil }