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