cashu.go raw
1 package cashu
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "strconv"
8 "time"
9
10 "github.com/elnosh/gonuts/cashu/nuts/nut04"
11 "github.com/elnosh/gonuts/cashu/nuts/nut05"
12 "github.com/elnosh/gonuts/wallet"
13 "github.com/elnosh/gonuts/wallet/storage"
14 "github.com/getAlby/hub/config"
15 "github.com/getAlby/hub/constants"
16 "github.com/getAlby/hub/lnclient"
17 "github.com/getAlby/hub/logger"
18 decodepay "github.com/nbd-wtf/ln-decodepay"
19 "github.com/sirupsen/logrus"
20 )
21
22 const nodeCommandRestore = "restore"
23 const nodeCommandCheckMnemonic = "checkmnemonic"
24 const nodeCommandResetWallet = "reset"
25
26 type CashuService struct {
27 wallet *wallet.Wallet
28 workDir string
29 hasDifferentMnemonic bool
30 }
31
32 func NewCashuService(cfg config.Config, workDir, mnemonic, mintUrl string) (result lnclient.LNClient, err error) {
33 if workDir == "" {
34 return nil, errors.New("one or more required cashu configuration are missing")
35 }
36 if mintUrl == "" {
37 return nil, errors.New("no mint URL configured")
38 }
39
40 _, err = os.Stat(workDir)
41 isFirstSetup := err != nil && errors.Is(err, os.ErrNotExist)
42
43 if isFirstSetup {
44 // make the cashu wallet use the Alby Hub provided mnemonic
45 wallet.Restore(workDir, mnemonic, []string{mintUrl})
46 }
47
48 logger.Logger.WithField("mintUrl", mintUrl).Info("Setting up cashu wallet")
49 config := wallet.Config{WalletPath: workDir, CurrentMintURL: mintUrl}
50
51 cashuWallet, err := wallet.LoadWallet(config)
52 if err != nil {
53 logger.Logger.WithError(err).Error("Failed to load cashu wallet")
54 return nil, err
55 }
56
57 cs := CashuService{
58 wallet: cashuWallet,
59 workDir: workDir,
60 }
61
62 if cs.wallet.Mnemonic() != mnemonic {
63 logger.Logger.Warn("Cashu is not using Alby Hub mnemonic!")
64 cs.hasDifferentMnemonic = true
65 }
66
67 return &cs, nil
68 }
69
70 func (cs *CashuService) Shutdown() error {
71 return cs.wallet.Shutdown()
72 }
73
74 func (cs *CashuService) SendPaymentSync(invoice string, amountMsat *uint64) (response *lnclient.PayInvoiceResponse, err error) {
75 // TODO: support 0-amount invoices
76 if amountMsat != nil {
77 return nil, errors.New("0-amount invoices not supported")
78 }
79
80 meltQuoteResponse, err := cs.wallet.RequestMeltQuote(invoice, cs.wallet.CurrentMint())
81 if err != nil {
82 logger.Logger.WithError(err).Error("Failed to request melt quote")
83 return nil, err
84 }
85
86 meltResponse, err := cs.wallet.Melt(meltQuoteResponse.Quote)
87 if err != nil {
88 logger.Logger.WithError(err).Error("Failed to melt invoice")
89 return nil, err
90 }
91
92 if meltResponse == nil || meltResponse.Preimage == "" {
93 return nil, errors.New("no preimage in melt response")
94 }
95 fee := meltResponse.FeeReserve - meltResponse.Change.Amount()
96
97 return &lnclient.PayInvoiceResponse{
98 Preimage: meltResponse.Preimage,
99 FeeMsat: fee * 1000,
100 }, nil
101 }
102
103 func (cs *CashuService) SendKeysend(amountMsat uint64, destination string, custom_records []lnclient.TLVRecord, preimage string) (*lnclient.PayKeysendResponse, error) {
104 return nil, errors.New("keysend not supported")
105 }
106
107 func (cs *CashuService) MakeInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, throughNodePubkey *string) (transaction *lnclient.Transaction, err error) {
108 // TODO: support expiry
109 if expiry == 0 {
110 expiry = lnclient.DEFAULT_INVOICE_EXPIRY
111 }
112 mintResponse, err := cs.wallet.RequestMint(uint64(amountMsat/1000), cs.wallet.CurrentMint())
113 if err != nil {
114 logger.Logger.WithError(err).Error("Failed to mint")
115 return nil, err
116 }
117
118 mintQuote := cs.wallet.GetMintQuoteById(mintResponse.Quote)
119 return cs.cashuMintQuoteToTransaction(mintQuote), nil
120 }
121
122 func (cs *CashuService) MakeHoldInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, paymentHash string, minCltvExpiryDelta *uint64) (transaction *lnclient.Transaction, err error) {
123 _ = minCltvExpiryDelta
124 return nil, errors.New("not implemented")
125 }
126
127 func (cs *CashuService) SettleHoldInvoice(ctx context.Context, preimage string) (err error) {
128 return errors.New("not implemented")
129 }
130
131 func (cs *CashuService) CancelHoldInvoice(ctx context.Context, paymentHash string) (err error) {
132 return errors.New("not implemented")
133 }
134
135 func (cs *CashuService) LookupInvoice(ctx context.Context, paymentHash string) (transaction *lnclient.Transaction, err error) {
136 mintQuote := cs.getMintQuoteByPaymentHash(paymentHash)
137 if mintQuote != nil {
138 cs.checkIncomingPayment(mintQuote)
139 transaction = cs.cashuMintQuoteToTransaction(mintQuote)
140 return transaction, nil
141 }
142
143 meltQuote := cs.getMeltQuoteByPaymentHash(paymentHash)
144 if meltQuote != nil {
145 cs.checkOutgoingPayment(meltQuote)
146 transaction = cs.cashuMeltQuoteToTransaction(meltQuote)
147 return transaction, nil
148 }
149
150 logger.Logger.WithField("paymentHash", paymentHash).Error("Failed to lookup payment request by payment hash")
151 return nil, errors.New("failed to lookup payment request by payment hash")
152 }
153
154 func (cs *CashuService) GetInfo(ctx context.Context) (info *lnclient.NodeInfo, err error) {
155 return &lnclient.NodeInfo{
156 Alias: "NWC (Cashu)",
157 Color: "#897FFF",
158 Pubkey: "",
159 Network: "bitcoin",
160 BlockHeight: 0,
161 BlockHash: "",
162 }, nil
163 }
164
165 func (cs *CashuService) ListChannels(ctx context.Context) ([]lnclient.Channel, error) {
166 return nil, nil
167 }
168
169 func (cs *CashuService) GetNodeConnectionInfo(ctx context.Context) (nodeConnectionInfo *lnclient.NodeConnectionInfo, err error) {
170 return &lnclient.NodeConnectionInfo{}, nil
171 }
172
173 func (cs *CashuService) ConnectPeer(ctx context.Context, connectPeerRequest *lnclient.ConnectPeerRequest) error {
174 return errors.New("not implemented")
175 }
176
177 func (cs *CashuService) OpenChannel(ctx context.Context, openChannelRequest *lnclient.OpenChannelRequest) (*lnclient.OpenChannelResponse, error) {
178 return nil, errors.New("not implemented")
179 }
180
181 func (cs *CashuService) CloseChannel(ctx context.Context, closeChannelRequest *lnclient.CloseChannelRequest) error {
182 return errors.New("not implemented")
183 }
184
185 func (cs *CashuService) GetNewOnchainAddress(ctx context.Context) (string, error) {
186 return "", errors.New("not implemented")
187 }
188
189 func (cs *CashuService) GetOnchainBalance(ctx context.Context) (*lnclient.OnchainBalanceResponse, error) {
190 return &lnclient.OnchainBalanceResponse{}, nil
191 }
192
193 func (cs *CashuService) RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (string, error) {
194 return "", errors.New("not implemented")
195 }
196
197 func (cs *CashuService) ResetRouter(key string) error {
198 mnemonic := cs.wallet.Mnemonic()
199 currentMint := cs.wallet.CurrentMint()
200
201 if err := cs.wallet.Shutdown(); err != nil {
202 return err
203 }
204
205 if err := os.RemoveAll(cs.workDir); err != nil {
206 logger.Logger.WithError(err).Error("Failed to remove wallet directory")
207 return err
208 }
209
210 amountRestored, err := wallet.Restore(cs.workDir, mnemonic, []string{currentMint})
211 if err != nil {
212 logger.Logger.WithError(err).Error("Failed restore cashu wallet")
213 return err
214 }
215
216 logger.Logger.WithField("amountRestored", amountRestored).Info("Successfully restored cashu wallet")
217 return nil
218 }
219
220 func (cs *CashuService) SignMessage(ctx context.Context, message string) (string, error) {
221 return "", errors.New("not implemented")
222 }
223
224 func (cs *CashuService) DisconnectPeer(ctx context.Context, peerId string) error {
225 return errors.New("not implemented")
226 }
227
228 func (cs *CashuService) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) {
229 return nil, nil
230 }
231 func (cs *CashuService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, error) {
232 return nil, nil
233 }
234
235 func (cs *CashuService) GetStorageDir() (string, error) {
236 return "", nil
237 }
238 func (cs *CashuService) GetNetworkGraph(ctx context.Context, nodeIds []string) (lnclient.NetworkGraphResponse, error) {
239 return nil, nil
240 }
241 func (cs *CashuService) UpdateLastWalletSyncRequest() {}
242
243 func (cs *CashuService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) {
244 return &lnclient.NodeStatus{
245 IsReady: true,
246 }, nil
247 }
248
249 func (cs *CashuService) UpdateChannel(ctx context.Context, updateChannelRequest *lnclient.UpdateChannelRequest) error {
250 return errors.New("not implemented")
251 }
252
253 func (cs *CashuService) GetBalances(ctx context.Context, includeInactiveChannels bool) (*lnclient.BalancesResponse, error) {
254 cashuBalance := cs.wallet.GetBalance()
255 balance := int64(cashuBalance * 1000)
256
257 return &lnclient.BalancesResponse{
258 Onchain: lnclient.OnchainBalanceResponse{
259 PendingBalancesDetails: []lnclient.PendingBalanceDetails{},
260 PendingSweepBalancesDetails: []lnclient.PendingBalanceDetails{}},
261 Lightning: lnclient.LightningBalanceResponse{
262 TotalSpendableMsat: balance,
263 NextMaxSpendableMsat: balance,
264 NextMaxSpendableMPPMsat: balance,
265 },
266 }, nil
267 }
268
269 func (cs *CashuService) cashuMintQuoteToTransaction(mintQuote *storage.MintQuote) *lnclient.Transaction {
270 // note: if a mint quote exists, then the payment request is already valid
271 paymentRequest, _ := decodepay.Decodepay(mintQuote.PaymentRequest)
272 var settledAt *int64
273 if mintQuote.SettledAt > 0 {
274 settledAt = &mintQuote.SettledAt
275 }
276
277 var expiresAt *int64
278
279 expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
280 expiresAt = &expiresAtUnix
281 description := paymentRequest.Description
282 descriptionHash := paymentRequest.DescriptionHash
283
284 return &lnclient.Transaction{
285 Type: constants.TRANSACTION_TYPE_INCOMING,
286 Invoice: mintQuote.PaymentRequest,
287 PaymentHash: paymentRequest.PaymentHash,
288 // note: setting dummy preimage so that it gets marked as settled
289 Preimage: paymentRequest.PaymentHash,
290 AmountMsat: paymentRequest.MSatoshi,
291 CreatedAt: int64(paymentRequest.CreatedAt),
292 ExpiresAt: expiresAt,
293 Description: description,
294 DescriptionHash: descriptionHash,
295 SettledAt: settledAt,
296 }
297 }
298
299 func (cs *CashuService) cashuMeltQuoteToTransaction(meltQuote *storage.MeltQuote) *lnclient.Transaction {
300 // note: if a melt quote exists, then the payment request is already valid
301 paymentRequest, _ := decodepay.Decodepay(meltQuote.PaymentRequest)
302
303 var settledAt *int64
304 if meltQuote.SettledAt > 0 {
305 settledAt = &meltQuote.SettledAt
306 }
307
308 var expiresAt *int64
309
310 expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
311 expiresAt = &expiresAtUnix
312 description := paymentRequest.Description
313 descriptionHash := paymentRequest.DescriptionHash
314
315 return &lnclient.Transaction{
316 Type: constants.TRANSACTION_TYPE_OUTGOING,
317 Invoice: meltQuote.PaymentRequest,
318 PaymentHash: paymentRequest.PaymentHash,
319 AmountMsat: paymentRequest.MSatoshi,
320 CreatedAt: int64(paymentRequest.CreatedAt),
321 ExpiresAt: expiresAt,
322 Description: description,
323 DescriptionHash: descriptionHash,
324 Preimage: meltQuote.Preimage,
325 SettledAt: settledAt,
326 FeesPaidMsat: int64(meltQuote.FeeReserve * 1000),
327 }
328 }
329
330 func (cs *CashuService) getMintQuoteByPaymentHash(paymentHash string) *storage.MintQuote {
331 mintQuotes := cs.wallet.GetMintQuotes()
332
333 for _, mintQuote := range mintQuotes {
334 bolt11, err := decodepay.Decodepay(mintQuote.PaymentRequest)
335 if err != nil {
336 return nil
337 }
338 if bolt11.PaymentHash == paymentHash {
339 return &mintQuote
340 }
341 }
342
343 return nil
344 }
345
346 func (cs *CashuService) getMeltQuoteByPaymentHash(paymentHash string) *storage.MeltQuote {
347 meltQuotes := cs.wallet.GetMeltQuotes()
348
349 for _, meltQuote := range meltQuotes {
350 bolt11, err := decodepay.Decodepay(meltQuote.PaymentRequest)
351 if err != nil {
352 return nil
353 }
354 if bolt11.PaymentHash == paymentHash {
355 return &meltQuote
356 }
357 }
358
359 return nil
360 }
361
362 func (cs *CashuService) checkIncomingPayment(mintQuote *storage.MintQuote) {
363 bolt11, _ := decodepay.Decodepay(mintQuote.PaymentRequest)
364
365 if mintQuote.State != nut04.Paid {
366 logger.Logger.WithFields(logrus.Fields{
367 "paymentHash": bolt11.PaymentHash,
368 }).Debug("Checking unpaid invoice")
369
370 mintQuoteState, err := cs.wallet.MintQuoteState(mintQuote.QuoteId)
371 if err != nil {
372 logger.Logger.WithFields(logrus.Fields{
373 "paymentHash": bolt11.PaymentHash,
374 }).WithError(err).Warn("failed to check invoice state")
375 return
376 }
377
378 if mintQuoteState.State == nut04.Paid {
379 amountMinted, err := cs.wallet.MintTokens(mintQuote.QuoteId)
380 if err != nil {
381 logger.Logger.WithFields(logrus.Fields{
382 "paymentHash": bolt11.PaymentHash,
383 }).WithError(err).Warn("failed to mint")
384 }
385 if amountMinted > 0 {
386 logger.Logger.WithFields(logrus.Fields{
387 "paymentHash": bolt11.PaymentHash,
388 "amount": amountMinted,
389 }).Info("sats successfully minted")
390 }
391 }
392 }
393 }
394
395 func (cs *CashuService) checkOutgoingPayment(meltQuote *storage.MeltQuote) {
396 bolt11, _ := decodepay.Decodepay(meltQuote.PaymentRequest)
397
398 if meltQuote.State != nut05.Paid {
399 _, err := cs.wallet.CheckMeltQuoteState(meltQuote.QuoteId)
400 if err != nil {
401 logger.Logger.WithFields(logrus.Fields{
402 "paymentHash": bolt11.PaymentHash,
403 }).WithError(err).Warn("failed to check invoice state")
404 }
405
406 }
407
408 }
409
410 func (cs *CashuService) GetSupportedNIP47Methods() []string {
411 return []string{"pay_invoice", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"}
412 }
413
414 func (cs *CashuService) GetSupportedNIP47NotificationTypes() []string {
415 return []string{}
416 }
417
418 func (svc *CashuService) GetPubkey() string {
419 return ""
420 }
421
422 func (cs *CashuService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
423 return []lnclient.CustomNodeCommandDef{
424 {
425 Name: nodeCommandRestore,
426 Description: "Restore cashu tokens after the wallet had a stuck payment.",
427 Args: nil,
428 },
429 {
430 Name: nodeCommandCheckMnemonic,
431 Description: "Check if your cashu wallet uses the same mnemonic as Alby Hub.",
432 Args: nil,
433 },
434 {
435 Name: nodeCommandResetWallet,
436 Description: "Completely resets your cashu wallet. Only do this if you have no funds.",
437 Args: nil,
438 },
439 }
440 }
441
442 func (cs *CashuService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
443 switch command.Name {
444 case nodeCommandRestore:
445 return cs.executeCommandRestore()
446 case nodeCommandResetWallet:
447 return cs.executeCommandResetWallet()
448 case nodeCommandCheckMnemonic:
449 return &lnclient.CustomNodeCommandResponse{
450 Response: map[string]interface{}{
451 "matches": !cs.hasDifferentMnemonic,
452 },
453 }, nil
454 }
455
456 return nil, lnclient.ErrUnknownCustomNodeCommand
457 }
458
459 func (cs *CashuService) executeCommandRestore() (*lnclient.CustomNodeCommandResponse, error) {
460 mnemonic := cs.wallet.Mnemonic()
461 currentMintUrl := cs.wallet.CurrentMint()
462
463 if err := cs.wallet.Shutdown(); err != nil {
464 return nil, err
465 }
466
467 if err := os.Rename(cs.workDir, cs.workDir+strconv.FormatInt(time.Now().Unix(), 10)); err != nil {
468 logger.Logger.WithError(err).Error("Failed to rename wallet directory")
469 return nil, err
470 }
471
472 amountRestored, err := wallet.Restore(cs.workDir, mnemonic, []string{currentMintUrl})
473 if err != nil {
474 logger.Logger.WithError(err).Error("Failed restore cashu wallet")
475 return nil, err
476 }
477
478 logger.Logger.WithField("amountRestored", amountRestored).Info("Successfully restored cashu wallet")
479
480 config := wallet.Config{WalletPath: cs.workDir, CurrentMintURL: currentMintUrl}
481 cashuWallet, err := wallet.LoadWallet(config)
482 if err != nil {
483 logger.Logger.WithError(err).Error("Failed to load cashu wallet")
484 return nil, err
485 }
486
487 cs.wallet = cashuWallet
488
489 return &lnclient.CustomNodeCommandResponse{
490 Response: map[string]interface{}{
491 "amountRestored": amountRestored,
492 "message": "Restore successful.",
493 },
494 }, nil
495 }
496
497 func (cs *CashuService) executeCommandResetWallet() (*lnclient.CustomNodeCommandResponse, error) {
498 if err := cs.wallet.Shutdown(); err != nil {
499 return nil, err
500 }
501
502 if err := os.Rename(cs.workDir, cs.workDir+strconv.FormatInt(time.Now().Unix(), 10)); err != nil {
503 logger.Logger.WithError(err).Error("Failed to rename wallet directory")
504 return nil, err
505 }
506
507 go func() {
508 time.Sleep(10 * time.Second)
509 os.Exit(0)
510 }()
511
512 return &lnclient.CustomNodeCommandResponse{
513 Response: map[string]interface{}{
514 "message": "Reset successful. Your hub will shutdown in 10 seconds...",
515 },
516 }, nil
517 }
518
519 func (svc *CashuService) MakeOffer(ctx context.Context, description string) (string, error) {
520 return "", errors.New("not supported")
521 }
522
523 func (cs *CashuService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
524 return nil, errors.ErrUnsupported
525 }
526