mirror of
https://github.com/nbd-wtf/go-nostr.git
synced 2025-03-17 21:32:56 +01:00
nip60: fixes, actual Cashu stuff and a wallet.Receive() method.
This commit is contained in:
parent
3334f7a48b
commit
c6747cdf44
4
go.mod
4
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
|
||||
|
8
go.sum
8
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=
|
||||
|
362
nip60/client/client.go
Normal file
362
nip60/client/client.go
Normal file
@ -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
|
||||
}
|
264
nip60/helpers.go
Normal file
264
nip60/helpers.go
Normal file
@ -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
|
||||
}
|
@ -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 {
|
||||
|
10
nip60/lib.go
10
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,
|
||||
|
@ -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))
|
||||
|
143
nip60/receive.go
Normal file
143
nip60/receive.go
Normal file
@ -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
|
||||
}
|
134
nip60/swap.go
Normal file
134
nip60/swap.go
Normal file
@ -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
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user