nip60: wallet.SendToken() and wallet.SwapProofs()
This commit is contained in:
+18
-21
@@ -22,7 +22,7 @@ import (
|
|||||||
"github.com/elnosh/gonuts/crypto"
|
"github.com/elnosh/gonuts/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func calculateFee(inputs cashu.Proofs, keysets []nut02.Keyset) uint {
|
func calculateFee(inputs cashu.Proofs, keysets []nut02.Keyset) uint64 {
|
||||||
var n uint = 0
|
var n uint = 0
|
||||||
next:
|
next:
|
||||||
for _, proof := range inputs {
|
for _, proof := range inputs {
|
||||||
@@ -35,13 +35,14 @@ next:
|
|||||||
|
|
||||||
panic(fmt.Errorf("spending a proof we don't have the keyset for? %v // %v", proof, keysets))
|
panic(fmt.Errorf("spending a proof we don't have the keyset for? %v // %v", proof, keysets))
|
||||||
}
|
}
|
||||||
return (n + 999) / 1000
|
return uint64((n + 999) / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns blinded messages, secrets - [][]byte, and list of r
|
// returns blinded messages, secrets - [][]byte, and list of r
|
||||||
func createBlindedMessages(
|
func createBlindedMessages(
|
||||||
splitAmounts []uint64,
|
splitAmounts []uint64,
|
||||||
keysetId string,
|
keysetId string,
|
||||||
|
spendingCondition *nut10.SpendingCondition,
|
||||||
) (cashu.BlindedMessages, []string, []*secp256k1.PrivateKey, error) {
|
) (cashu.BlindedMessages, []string, []*secp256k1.PrivateKey, error) {
|
||||||
splitLen := len(splitAmounts)
|
splitLen := len(splitAmounts)
|
||||||
blindedMessages := make(cashu.BlindedMessages, splitLen)
|
blindedMessages := make(cashu.BlindedMessages, splitLen)
|
||||||
@@ -49,13 +50,25 @@ func createBlindedMessages(
|
|||||||
rs := make([]*secp256k1.PrivateKey, splitLen)
|
rs := make([]*secp256k1.PrivateKey, splitLen)
|
||||||
|
|
||||||
for i, amt := range splitAmounts {
|
for i, amt := range splitAmounts {
|
||||||
var secret string
|
r, err := secp256k1.GeneratePrivateKey()
|
||||||
var r *secp256k1.PrivateKey
|
|
||||||
secret, r, err := generateRandomSecret()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var secret string
|
||||||
|
if spendingCondition != nil {
|
||||||
|
secret, err = nut10.NewSecretFromSpendingCondition(*spendingCondition)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
secretBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(secretBytes); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
secret = hex.EncodeToString(secretBytes)
|
||||||
|
}
|
||||||
|
|
||||||
B_, r, err := crypto.BlindMessage(secret, r)
|
B_, r, err := crypto.BlindMessage(secret, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
@@ -69,22 +82,6 @@ func createBlindedMessages(
|
|||||||
return blindedMessages, secrets, rs, nil
|
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 {
|
func splitWalletTarget(proofs cashu.Proofs, amountToSplit uint64, mint string) []uint64 {
|
||||||
target := 3
|
target := 3
|
||||||
|
|
||||||
|
|||||||
@@ -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, nil)
|
||||||
|
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
|
||||||
|
}
|
||||||
+9
-50
@@ -5,10 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/btcsuite/btcd/btcec/v2"
|
|
||||||
"github.com/elnosh/gonuts/cashu"
|
"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/elnosh/gonuts/cashu/nuts/nut10"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/nbd-wtf/go-nostr/nip60/client"
|
"github.com/nbd-wtf/go-nostr/nip60/client"
|
||||||
@@ -21,9 +18,9 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
source := "http" + nostr.NormalizeURL(token.Mint())[2:]
|
source := "http" + nostr.NormalizeURL(token.Mint())[2:]
|
||||||
swap := slices.Contains(w.Mints, source)
|
lightningSwap := slices.Contains(w.Mints, source)
|
||||||
proofs := token.Proofs()
|
proofs := token.Proofs()
|
||||||
isp2pk := false
|
swapOpts := make([]SwapOption, 0, 1)
|
||||||
|
|
||||||
for i, proof := range proofs {
|
for i, proof := range proofs {
|
||||||
if proof.Secret != "" {
|
if proof.Secret != "" {
|
||||||
@@ -31,7 +28,7 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
switch nut10Secret.Kind {
|
switch nut10Secret.Kind {
|
||||||
case nut10.P2PK:
|
case nut10.P2PK:
|
||||||
isp2pk = true
|
swapOpts = append(swapOpts, WithSignedOutputs())
|
||||||
proofs[i].Witness, err = signInput(w.PrivateKey, w.PublicKey, proof, nut10Secret)
|
proofs[i].Witness, err = signInput(w.PrivateKey, w.PublicKey, proof, nut10Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to sign locked proof %d: %w", i, err)
|
return fmt.Errorf("failed to sign locked proof %d: %w", i, err)
|
||||||
@@ -49,58 +46,18 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get %s keysets: %w", source, err)
|
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
|
// get new proofs
|
||||||
splits := make([]uint64, len(proofs))
|
_, newProofs, err := w.SwapProofs(ctx, source, proofs, proofs.Amount(), swapOpts...)
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create blinded message: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if isp2pk {
|
newMint := source // if we don't have to do a lightning swap then new mint will be the same as old mint
|
||||||
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 claim received tokens at %s: %w", source, 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
|
// 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
|
// and telling the current mint to pay it
|
||||||
if swap {
|
if lightningSwap {
|
||||||
for _, targetMint := range w.Mints {
|
for _, targetMint := range w.Mints {
|
||||||
swappedProofs, err, tryAnother, needsManualAction := lightningMeltMint(
|
swappedProofs, err, tryAnother, needsManualAction := lightningMeltMint(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -132,11 +89,13 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveproofs:
|
saveproofs:
|
||||||
|
w.tokensMu.Lock()
|
||||||
w.Tokens = append(w.Tokens, Token{
|
w.Tokens = append(w.Tokens, Token{
|
||||||
Mint: newMint,
|
Mint: newMint,
|
||||||
Proofs: newProofs,
|
Proofs: newProofs,
|
||||||
mintedAt: nostr.Now(),
|
mintedAt: nostr.Now(),
|
||||||
})
|
})
|
||||||
|
w.tokensMu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package nip60
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"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/nut10"
|
||||||
|
"github.com/elnosh/gonuts/cashu/nuts/nut11"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip60/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SendOption func(opts *sendSettings)
|
||||||
|
|
||||||
|
type sendSettings struct {
|
||||||
|
specificMint string
|
||||||
|
p2pk *btcec.PublicKey
|
||||||
|
refundtimelock int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithP2PK(pubkey string) SendOption {
|
||||||
|
return func(opts *sendSettings) {
|
||||||
|
pkb, _ := hex.DecodeString(pubkey)
|
||||||
|
opts.p2pk, _ = btcec.ParsePubKey(pkb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithRefundable(timelock nostr.Timestamp) SendOption {
|
||||||
|
return func(opts *sendSettings) {
|
||||||
|
opts.refundtimelock = int64(timelock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithMint(url string) SendOption {
|
||||||
|
return func(opts *sendSettings) {
|
||||||
|
opts.specificMint = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOption) (string, error) {
|
||||||
|
ss := &sendSettings{}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(ss)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.tokensMu.Lock()
|
||||||
|
defer w.tokensMu.Unlock()
|
||||||
|
|
||||||
|
type part struct {
|
||||||
|
mint string
|
||||||
|
tokens []Token
|
||||||
|
tokenIndexes []int
|
||||||
|
proofs cashu.Proofs
|
||||||
|
keysets []nut02.Keyset
|
||||||
|
}
|
||||||
|
|
||||||
|
var target part
|
||||||
|
byMint := make(map[string]part)
|
||||||
|
for t, token := range w.Tokens {
|
||||||
|
if ss.specificMint != "" && token.Mint != ss.specificMint {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
part, ok := byMint[token.Mint]
|
||||||
|
if !ok {
|
||||||
|
keysets, err := client.GetAllKeysets(ctx, token.Mint)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get %s keysets: %w", token.Mint, err)
|
||||||
|
}
|
||||||
|
part.keysets = keysets
|
||||||
|
part.tokens = make([]Token, 0, 3)
|
||||||
|
part.tokenIndexes = make([]int, 0, 3)
|
||||||
|
part.proofs = make(cashu.Proofs, 0, 7)
|
||||||
|
part.mint = token.Mint
|
||||||
|
}
|
||||||
|
|
||||||
|
part.tokens = append(part.tokens, token)
|
||||||
|
part.tokenIndexes = append(part.tokenIndexes, t)
|
||||||
|
part.proofs = append(part.proofs, token.Proofs...)
|
||||||
|
if part.proofs.Amount() >= amount {
|
||||||
|
// maybe we found it here
|
||||||
|
fee := calculateFee(part.proofs, part.keysets)
|
||||||
|
if part.proofs.Amount() >= (amount + fee) {
|
||||||
|
// yes, we did
|
||||||
|
target = part
|
||||||
|
goto found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we got here it's because we didn't get enough proofs from the same mint
|
||||||
|
return "", fmt.Errorf("not enough proofs found from the same mint")
|
||||||
|
|
||||||
|
found:
|
||||||
|
swapOpts := make([]SwapOption, 0, 2)
|
||||||
|
|
||||||
|
if ss.p2pk != nil {
|
||||||
|
if info, err := client.GetMintInfo(ctx, target.mint); err != nil || !info.Nuts.Nut11.Supported {
|
||||||
|
return "", fmt.Errorf("mint doesn't support p2pk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := nut11.P2PKTags{
|
||||||
|
NSigs: 1,
|
||||||
|
Locktime: 0,
|
||||||
|
Pubkeys: []*btcec.PublicKey{ss.p2pk},
|
||||||
|
}
|
||||||
|
if ss.refundtimelock != 0 {
|
||||||
|
tags.Refund = []*btcec.PublicKey{w.PublicKey}
|
||||||
|
tags.Locktime = ss.refundtimelock
|
||||||
|
}
|
||||||
|
|
||||||
|
swapOpts = append(swapOpts, WithSpendingCondition(
|
||||||
|
nut10.SpendingCondition{
|
||||||
|
Kind: nut10.P2PK,
|
||||||
|
Data: hex.EncodeToString(ss.p2pk.SerializeCompressed()),
|
||||||
|
Tags: nut11.SerializeP2PKTags(tags),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// get new proofs
|
||||||
|
proofsToSend, changeProofs, err := w.SwapProofs(ctx, target.mint, target.proofs, amount, swapOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete spent tokens and save our change
|
||||||
|
newTokens := make([]Token, 0, len(w.Tokens))
|
||||||
|
for i, token := range w.Tokens {
|
||||||
|
if slices.Contains(target.tokenIndexes, i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newTokens = append(newTokens, token)
|
||||||
|
}
|
||||||
|
w.Tokens = append(newTokens, Token{
|
||||||
|
mintedAt: nostr.Now(),
|
||||||
|
Mint: target.mint,
|
||||||
|
Proofs: changeProofs,
|
||||||
|
})
|
||||||
|
|
||||||
|
// serialize token we're sending out
|
||||||
|
token, err := cashu.NewTokenV4(proofsToSend, target.mint, cashu.Sat, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.Serialize()
|
||||||
|
}
|
||||||
@@ -90,9 +90,12 @@ func LoadStash(
|
|||||||
for _, he := range wl.pendingHistory[wallet.Identifier] {
|
for _, he := range wl.pendingHistory[wallet.Identifier] {
|
||||||
wallet.History = append(wallet.History, he)
|
wallet.History = append(wallet.History, he)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wallet.tokensMu.Lock()
|
||||||
for _, token := range wl.pendingTokens[wallet.Identifier] {
|
for _, token := range wl.pendingTokens[wallet.Identifier] {
|
||||||
wallet.Tokens = append(wallet.Tokens, token)
|
wallet.Tokens = append(wallet.Tokens, token)
|
||||||
}
|
}
|
||||||
|
wallet.tokensMu.Unlock()
|
||||||
|
|
||||||
wl.wallets[wallet.Identifier] = wallet
|
wl.wallets[wallet.Identifier] = wallet
|
||||||
|
|
||||||
@@ -124,7 +127,9 @@ func LoadStash(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if wallet, ok := wl.wallets[spl[2]]; ok {
|
if wallet, ok := wl.wallets[spl[2]]; ok {
|
||||||
|
wallet.tokensMu.Lock()
|
||||||
wallet.Tokens = append(wallet.Tokens, token)
|
wallet.Tokens = append(wallet.Tokens, token)
|
||||||
|
wallet.tokensMu.Unlock()
|
||||||
} else {
|
} else {
|
||||||
wl.pendingTokens[spl[2]] = append(wl.pendingTokens[spl[2]], token)
|
wl.pendingTokens[spl[2]] = append(wl.pendingTokens[spl[2]], token)
|
||||||
}
|
}
|
||||||
+84
-106
@@ -3,132 +3,110 @@ package nip60
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/elnosh/gonuts/cashu"
|
"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/nut04"
|
"github.com/elnosh/gonuts/cashu/nuts/nut10"
|
||||||
"github.com/elnosh/gonuts/cashu/nuts/nut05"
|
|
||||||
"github.com/nbd-wtf/go-nostr/nip60/client"
|
"github.com/nbd-wtf/go-nostr/nip60/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// lightningMeltMint does the lightning dance of moving funds between mints
|
type SwapOption func(*swapSettings)
|
||||||
func lightningMeltMint(
|
|
||||||
|
func WithSignedOutputs() SwapOption {
|
||||||
|
return func(ss *swapSettings) {
|
||||||
|
ss.mustSignOutputs = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSpendingCondition(sc nut10.SpendingCondition) SwapOption {
|
||||||
|
return func(ss *swapSettings) {
|
||||||
|
ss.spendingCondition = &sc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type swapSettings struct {
|
||||||
|
spendingCondition *nut10.SpendingCondition
|
||||||
|
mustSignOutputs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wallet) SwapProofs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
mint string,
|
||||||
proofs cashu.Proofs,
|
proofs cashu.Proofs,
|
||||||
from string,
|
targetAmount uint64,
|
||||||
fromKeysets []nut02.Keyset,
|
opts ...SwapOption,
|
||||||
to string,
|
) (principal cashu.Proofs, change cashu.Proofs, err error) {
|
||||||
) (newProofs cashu.Proofs, err error, canTryWithAnotherTargetMint bool, manualActionRequired bool) {
|
var ss swapSettings
|
||||||
// get active keyset of target mint
|
for _, opt := range opts {
|
||||||
keyset, err := client.GetActiveKeyset(ctx, to)
|
opt(&ss)
|
||||||
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
|
// fetch all this keyset drama first
|
||||||
keysetKeys, err := parseKeysetKeys(keyset.Keys)
|
keysets, err := client.GetAllKeysets(ctx, mint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("target mint %s sent us an invalid keyset: %w", to, err), true, false
|
return nil, nil, fmt.Errorf("failed to get all keysets for %s: %w", mint, err)
|
||||||
|
}
|
||||||
|
activeKeyset, err := client.GetActiveKeyset(ctx, mint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to get active keyset for %s: %w", mint, err)
|
||||||
|
}
|
||||||
|
ksKeys, err := parseKeysetKeys(activeKeyset.Keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse keys for %s: %w", mint, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// now we start the melt-mint process in multiple attempts
|
// decide the shape of the proofs we'll swap for
|
||||||
invoicePct := 0.99
|
|
||||||
proofsAmount := proofs.Amount()
|
proofsAmount := proofs.Amount()
|
||||||
amount := float64(proofsAmount) * invoicePct
|
var (
|
||||||
fee := uint64(calculateFee(proofs, fromKeysets))
|
principalAmount uint64
|
||||||
var meltQuote string
|
changeAmount uint64
|
||||||
var mintQuote string
|
)
|
||||||
for range 10 {
|
fee := calculateFee(proofs, keysets)
|
||||||
// request _mint_ quote to the 'to' mint -- this will generate an invoice
|
if targetAmount < proofsAmount {
|
||||||
mintResp, err := client.PostMintQuoteBolt11(ctx, to, nut04.PostMintQuoteBolt11Request{
|
// we'll get the exact target, then a change, and fee will be taken from the change
|
||||||
Amount: uint64(amount) - fee,
|
changeAmount = proofsAmount - targetAmount - fee
|
||||||
Unit: cashu.Sat.String(),
|
} else if targetAmount == proofsAmount {
|
||||||
})
|
// we're swapping everything, so take the fee from the principal
|
||||||
if err != nil {
|
principalAmount = targetAmount - fee
|
||||||
return nil, fmt.Errorf("error requesting mint quote from %s: %w", to, err), true, false
|
} else {
|
||||||
}
|
return nil, nil, fmt.Errorf("can't swap for more than we are sending: %d > %d",
|
||||||
|
targetAmount, proofsAmount)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
splits := make([]uint64, 0, len(proofs)*2)
|
||||||
|
splits = append(splits, cashu.AmountSplit(principalAmount)...)
|
||||||
|
changeStartIndex := len(splits)
|
||||||
|
splits = append(splits, cashu.AmountSplit(changeAmount)...)
|
||||||
|
|
||||||
return nil, fmt.Errorf("stop trying to do the melt because the mint part is too expensive"), true, false
|
// prepare message to send to mint
|
||||||
|
outputs, secrets, rs, err := createBlindedMessages(splits, activeKeyset.Id, ss.spendingCondition)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error melting token: %v", err), false, true
|
return nil, nil, fmt.Errorf("failed to create blinded message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sleepTime := time.Millisecond * 200
|
if ss.mustSignOutputs {
|
||||||
failures := 0
|
for i, output := range outputs {
|
||||||
for range 12 {
|
outputs[i].Witness, err = signOutput(w.PrivateKey, output)
|
||||||
sleepTime *= 2
|
if err != nil {
|
||||||
time.Sleep(sleepTime)
|
return nil, nil, fmt.Errorf("failed to sign output message %d: %w", i, err)
|
||||||
|
|
||||||
// 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
|
req := nut03.PostSwapRequest{
|
||||||
|
Inputs: proofs,
|
||||||
|
Outputs: outputs,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.PostSwap(ctx, mint, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to claim received tokens at %s: %w", mint, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// build the proofs locally from mint's response
|
||||||
|
newProofs, err := constructProofs(res.Signatures, req.Outputs, secrets, rs, ksKeys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to construct proofs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newProofs[0:changeStartIndex], newProofs[changeStartIndex:], nil
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/btcsuite/btcd/btcec/v2"
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
@@ -24,9 +25,10 @@ type Wallet struct {
|
|||||||
History []HistoryEntry
|
History []HistoryEntry
|
||||||
|
|
||||||
temporaryBalance uint64
|
temporaryBalance uint64
|
||||||
|
tokensMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w Wallet) Balance() uint64 {
|
func (w *Wallet) Balance() uint64 {
|
||||||
var sum uint64
|
var sum uint64
|
||||||
for _, token := range w.Tokens {
|
for _, token := range w.Tokens {
|
||||||
sum += token.Proofs.Amount()
|
sum += token.Proofs.Amount()
|
||||||
@@ -34,14 +36,14 @@ func (w Wallet) Balance() uint64 {
|
|||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w Wallet) DisplayName() string {
|
func (w *Wallet) DisplayName() string {
|
||||||
if w.Name != "" {
|
if w.Name != "" {
|
||||||
return fmt.Sprintf("%s (%s)", w.Name, w.Identifier)
|
return fmt.Sprintf("%s (%s)", w.Name, w.Identifier)
|
||||||
}
|
}
|
||||||
return w.Identifier
|
return w.Identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w Wallet) ToPublishableEvents(
|
func (w *Wallet) ToPublishableEvents(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
kr nostr.Keyer,
|
kr nostr.Keyer,
|
||||||
skipExisting bool,
|
skipExisting bool,
|
||||||
@@ -91,6 +93,7 @@ func (w Wallet) ToPublishableEvents(
|
|||||||
events := make([]nostr.Event, 0, 1+len(w.Tokens))
|
events := make([]nostr.Event, 0, 1+len(w.Tokens))
|
||||||
events = append(events, evt)
|
events = append(events, evt)
|
||||||
|
|
||||||
|
w.tokensMu.Lock()
|
||||||
for _, t := range w.Tokens {
|
for _, t := range w.Tokens {
|
||||||
var evt nostr.Event
|
var evt nostr.Event
|
||||||
|
|
||||||
@@ -108,6 +111,7 @@ func (w Wallet) ToPublishableEvents(
|
|||||||
|
|
||||||
events = append(events, evt)
|
events = append(events, evt)
|
||||||
}
|
}
|
||||||
|
w.tokensMu.Unlock()
|
||||||
|
|
||||||
for _, h := range w.History {
|
for _, h := range w.History {
|
||||||
var evt nostr.Event
|
var evt nostr.Event
|
||||||
|
|||||||
Reference in New Issue
Block a user