nip60: fixes, actual Cashu stuff and a wallet.Receive() method.

This commit is contained in:
fiatjaf 2025-01-27 16:33:33 -03:00
parent 3334f7a48b
commit c6747cdf44
11 changed files with 977 additions and 42 deletions

4
go.mod
View File

@ -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
View File

@ -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
View 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
View 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
}

View File

@ -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 {

View File

@ -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,

View File

@ -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
View 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
View 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
}

View File

@ -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 {

View File

@ -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
}