nip60: wallet.PayBolt11()

This commit is contained in:
fiatjaf
2025-01-29 14:54:55 -03:00
parent 26597452c5
commit 7c04b497ec
4 changed files with 207 additions and 67 deletions

View File

@@ -42,9 +42,9 @@ func lightningMeltMint(
} }
// now we start the melt-mint process in multiple attempts // now we start the melt-mint process in multiple attempts
invoicePct := 0.99 invoicePct := uint64(99)
proofsAmount := proofs.Amount() proofsAmount := proofs.Amount()
amount := float64(proofsAmount) * invoicePct amount := proofsAmount * invoicePct / 100
fee := uint64(calculateFee(proofs, fromKeysets)) fee := uint64(calculateFee(proofs, fromKeysets))
var meltQuote string var meltQuote string
var mintQuote string var mintQuote string
@@ -65,14 +65,14 @@ func lightningMeltMint(
Unit: cashu.Sat.String(), Unit: cashu.Sat.String(),
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("error requesting melt quote from %s: %w", from, err), nothingCanBeDone return nil, fmt.Errorf("error requesting melt quote from %s: %w", from, err), storeTokenFromSourceMint
} }
// if amount in proofs is less than amount asked from mint in melt request, // if amount in proofs is less than amount asked from mint in melt request,
// lower the amount for mint request (because of lighting fees?) // lower the amount for mint request (because of lighting fees)
if meltResp.Amount+meltResp.FeeReserve+fee > proofsAmount { if meltResp.Amount+meltResp.FeeReserve+fee > proofsAmount {
invoicePct -= 0.01 invoicePct--
amount *= invoicePct amount = proofsAmount * invoicePct / 100
} else { } else {
meltQuote = meltResp.Quote meltQuote = meltResp.Quote
mintQuote = mintResp.Quote mintQuote = mintResp.Quote
@@ -124,7 +124,7 @@ inspectmeltstatusresponse:
} }
// if it got paid make proceed to get proofs // if it got paid make proceed to get proofs
split := []uint64{1, 2, 3, 4} split := cashu.AmountSplit(amount)
blindedMessages, secrets, rs, err := createBlindedMessages(split, keyset.Id, nil) blindedMessages, secrets, rs, err := createBlindedMessages(split, keyset.Id, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating blinded messages: %v", err), manualActionRequired return nil, fmt.Errorf("error creating blinded messages: %v", err), manualActionRequired

96
nip60/pay.go Normal file
View File

@@ -0,0 +1,96 @@
package nip60
import (
"context"
"fmt"
"time"
"github.com/elnosh/gonuts/cashu"
"github.com/elnosh/gonuts/cashu/nuts/nut05"
"github.com/nbd-wtf/go-nostr/nip60/client"
)
func (w *Wallet) PayBolt11(ctx context.Context, invoice string, opts ...SendOption) (string, error) {
ss := &sendSettings{}
for _, opt := range opts {
opt(ss)
}
invoiceAmount, err := getSatoshisAmountFromBolt11(invoice)
if err != nil {
return "", err
}
w.tokensMu.Lock()
defer w.tokensMu.Unlock()
var chosen chosenTokens
var meltQuote string
var meltAmount uint64
invoicePct := uint64(99)
for range 10 {
amount := invoiceAmount * invoicePct / 100
var fee uint64
chosen, fee, err = w.getProofsForSending(ctx, amount, ss.specificMint)
if err != nil {
return "", err
}
// request _melt_ quote (ask the mint how much will it cost to pay a bolt11 invoice)
meltResp, err := client.PostMeltQuoteBolt11(ctx, chosen.mint, nut05.PostMeltQuoteBolt11Request{
Request: invoice,
Unit: cashu.Sat.String(),
})
if err != nil {
return "", fmt.Errorf("error requesting melt quote from %s: %w", chosen.mint, err)
}
// if amount in proofs is not sufficient to pay for the melt request,
// increase the amount and get proofs again (because of lighting fees)
if meltResp.Amount+meltResp.FeeReserve+fee > chosen.proofs.Amount() {
invoicePct--
} else {
meltQuote = meltResp.Quote
meltAmount = meltResp.Amount
goto meltworked
}
}
return "", fmt.Errorf("stop trying to do the melt because the mint part is too expensive")
meltworked:
// swap our proofs so we get the exact amount for paying the invoice
principal, change, err := w.SwapProofs(ctx, chosen.mint, chosen.proofs, meltAmount)
if err != nil {
return "", fmt.Errorf("failed to swap at %s into the exact melt amount: %w", chosen.mint, err)
}
if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, change, chosen.tokenIndexes); err != nil {
return "", err
}
// request from mint to _melt_ into paying the invoice
delay := 200 * time.Millisecond
// this request will block until the invoice is paid or it fails
// (but the API also says it can return "pending" so we handle both)
meltStatus, err := client.PostMeltBolt11(ctx, chosen.mint, nut05.PostMeltBolt11Request{
Quote: meltQuote,
Inputs: principal,
})
inspectmeltstatusresponse:
if err != nil || meltStatus.State == nut05.Unpaid {
return "", fmt.Errorf("error melting token: %w", err)
} else if meltStatus.State == nut05.Unknown {
return "", fmt.Errorf("we don't know what happened with the melt at %s: %v", chosen.mint, meltStatus)
} else if meltStatus.State == nut05.Pending {
for {
time.Sleep(delay)
delay *= 2
meltStatus, err = client.GetMeltQuoteState(ctx, chosen.mint, meltStatus.Quote)
goto inspectmeltstatusresponse
}
}
return meltStatus.Preimage, nil
}

View File

@@ -42,6 +42,14 @@ func WithMint(url string) SendOption {
} }
} }
type chosenTokens struct {
mint string
tokens []Token
tokenIndexes []int
proofs cashu.Proofs
keysets []nut02.Keyset
}
func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOption) (string, error) { func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOption) (string, error) {
ss := &sendSettings{} ss := &sendSettings{}
for _, opt := range opts { for _, opt := range opts {
@@ -51,56 +59,15 @@ func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOptio
w.tokensMu.Lock() w.tokensMu.Lock()
defer w.tokensMu.Unlock() defer w.tokensMu.Unlock()
type part struct { chosen, _, err := w.getProofsForSending(ctx, amount, ss.specificMint)
mint string if err != nil {
tokens []Token return "", err
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) swapOpts := make([]SwapOption, 0, 2)
if ss.p2pk != nil { if ss.p2pk != nil {
if info, err := client.GetMintInfo(ctx, target.mint); err != nil || !info.Nuts.Nut11.Supported { if info, err := client.GetMintInfo(ctx, chosen.mint); err != nil || !info.Nuts.Nut11.Supported {
return "", fmt.Errorf("mint doesn't support p2pk: %w", err) return "", fmt.Errorf("mint doesn't support p2pk: %w", err)
} }
@@ -124,24 +91,47 @@ found:
} }
// get new proofs // get new proofs
proofsToSend, changeProofs, err := w.SwapProofs(ctx, target.mint, target.proofs, amount, swapOpts...) proofsToSend, changeProofs, err := w.SwapProofs(ctx, chosen.mint, chosen.proofs, amount, swapOpts...)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, changeProofs, chosen.tokenIndexes); err != nil {
return "", err
}
// serialize token we're sending out
token, err := cashu.NewTokenV4(proofsToSend, chosen.mint, cashu.Sat, true)
if err != nil {
return "", err
}
wevt := nostr.Event{}
w.toEvent(ctx, w.wl.kr, &wevt)
w.wl.Changes <- wevt
return token.Serialize()
}
func (w *Wallet) saveChangeAndDeleteUsedTokens(
ctx context.Context,
mintURL string,
changeProofs cashu.Proofs,
usedTokenIndexes []int,
) error {
// delete spent tokens and save our change // delete spent tokens and save our change
updatedTokens := make([]Token, 0, len(w.Tokens)) updatedTokens := make([]Token, 0, len(w.Tokens))
changeToken := Token{ changeToken := Token{
mintedAt: nostr.Now(), mintedAt: nostr.Now(),
Mint: target.mint, Mint: mintURL,
Proofs: changeProofs, Proofs: changeProofs,
Deleted: make([]string, 0, len(target.tokenIndexes)), Deleted: make([]string, 0, len(usedTokenIndexes)),
event: &nostr.Event{}, event: &nostr.Event{},
} }
for i, token := range w.Tokens { for i, token := range w.Tokens {
if slices.Contains(target.tokenIndexes, i) { if slices.Contains(usedTokenIndexes, i) {
if token.event != nil { if token.event != nil {
token.Deleted = append(token.Deleted, token.event.ID) token.Deleted = append(token.Deleted, token.event.ID)
@@ -160,21 +150,51 @@ found:
if len(changeToken.Proofs) > 0 { if len(changeToken.Proofs) > 0 {
if err := changeToken.toEvent(ctx, w.wl.kr, w.Identifier, changeToken.event); err != nil { if err := changeToken.toEvent(ctx, w.wl.kr, w.Identifier, changeToken.event); err != nil {
return "", fmt.Errorf("failed to make change token: %w", err) return fmt.Errorf("failed to make change token: %w", err)
} }
w.wl.Changes <- *changeToken.event w.wl.Changes <- *changeToken.event
w.Tokens = append(updatedTokens, changeToken) w.Tokens = append(updatedTokens, changeToken)
} }
// serialize token we're sending out return nil
token, err := cashu.NewTokenV4(proofsToSend, target.mint, cashu.Sat, true) }
if err != nil {
return "", err func (w *Wallet) getProofsForSending(
ctx context.Context,
amount uint64,
specificMint string,
) (chosenTokens, uint64, error) {
byMint := make(map[string]chosenTokens)
for t, token := range w.Tokens {
if specificMint != "" && token.Mint != specificMint {
continue
}
part, ok := byMint[token.Mint]
if !ok {
keysets, err := client.GetAllKeysets(ctx, token.Mint)
if err != nil {
return chosenTokens{}, 0, 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.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
return part, fee, nil
}
}
} }
wevt := nostr.Event{} // if we got here it's because we didn't get enough proofs from the same mint
w.toEvent(ctx, w.wl.kr, &wevt) return chosenTokens{}, 0, fmt.Errorf("not enough proofs found from the same mint")
w.wl.Changes <- wevt
return token.Serialize()
} }

View File

@@ -37,18 +37,42 @@ func LoadStash(
kr nostr.Keyer, kr nostr.Keyer,
pool *nostr.SimplePool, pool *nostr.SimplePool,
relays []string, relays []string,
) *WalletStash {
return loadStashFromPool(ctx, kr, pool, relays, false)
}
func LoadStashWithHistory(
ctx context.Context,
kr nostr.Keyer,
pool *nostr.SimplePool,
relays []string,
) *WalletStash {
return loadStashFromPool(ctx, kr, pool, relays, true)
}
func loadStashFromPool(
ctx context.Context,
kr nostr.Keyer,
pool *nostr.SimplePool,
relays []string,
withHistory bool,
) *WalletStash { ) *WalletStash {
pk, err := kr.GetPublicKey(ctx) pk, err := kr.GetPublicKey(ctx)
if err != nil { if err != nil {
return nil return nil
} }
kinds := []int{37375, 7375}
if withHistory {
kinds = append(kinds, 7375)
}
eoseChan := make(chan struct{}) eoseChan := make(chan struct{})
events := pool.SubManyNotifyEOSE( events := pool.SubManyNotifyEOSE(
ctx, ctx,
relays, relays,
nostr.Filters{ nostr.Filters{
{Kinds: []int{37375, 7375, 7376}, Authors: []string{pk}}, {Kinds: kinds, Authors: []string{pk}},
{Kinds: []int{5}, Tags: nostr.TagMap{"k": []string{"7375"}}, Authors: []string{pk}}, {Kinds: []int{5}, Tags: nostr.TagMap{"k": []string{"7375"}}, Authors: []string{pk}},
}, },
eoseChan, eoseChan,